diff --git a/apps/api/src/modules/auth/application/__tests__/commands-queries.spec.ts b/apps/api/src/modules/auth/application/__tests__/commands-queries.spec.ts new file mode 100644 index 0000000..dc00f57 --- /dev/null +++ b/apps/api/src/modules/auth/application/__tests__/commands-queries.spec.ts @@ -0,0 +1,156 @@ +import { describe, it, expect } from 'vitest'; +import { CancelUserDeletionCommand } from '../commands/cancel-user-deletion/cancel-user-deletion.command'; +import { ExportUserDataCommand } from '../commands/export-user-data/export-user-data.command'; +import { ForceDeleteUserCommand } from '../commands/force-delete-user/force-delete-user.command'; +import { LoginUserCommand } from '../commands/login-user/login-user.command'; +import { ProcessScheduledDeletionsCommand } from '../commands/process-scheduled-deletions/process-scheduled-deletions.command'; +import { RefreshTokenCommand } from '../commands/refresh-token/refresh-token.command'; +import { RegisterUserCommand } from '../commands/register-user/register-user.command'; +import { RequestUserDeletionCommand } from '../commands/request-user-deletion/request-user-deletion.command'; +import { VerifyKycCommand } from '../commands/verify-kyc/verify-kyc.command'; +import { GetAgentByUserIdQuery } from '../queries/get-agent-by-user-id/get-agent-by-user-id.query'; +import { GetProfileQuery } from '../queries/get-profile/get-profile.query'; + +describe('Auth Commands & Queries', () => { + describe('RegisterUserCommand', () => { + it('stores all required properties', () => { + const cmd = new RegisterUserCommand('0912345678', 'P@ssw0rd!', 'Nguyen Van A'); + expect(cmd.phone).toBe('0912345678'); + expect(cmd.password).toBe('P@ssw0rd!'); + expect(cmd.fullName).toBe('Nguyen Van A'); + }); + + it('stores optional email', () => { + const cmd = new RegisterUserCommand('0912345678', 'P@ssw0rd!', 'Nguyen Van A', 'test@email.com'); + expect(cmd.email).toBe('test@email.com'); + }); + + it('email is undefined when not provided', () => { + const cmd = new RegisterUserCommand('0912345678', 'P@ssw0rd!', 'Nguyen Van A'); + expect(cmd.email).toBeUndefined(); + }); + }); + + describe('LoginUserCommand', () => { + it('stores userId, phone, and role', () => { + const cmd = new LoginUserCommand('user-1', '0912345678', 'BUYER'); + expect(cmd.userId).toBe('user-1'); + expect(cmd.phone).toBe('0912345678'); + expect(cmd.role).toBe('BUYER'); + }); + + it('accepts different roles', () => { + const cmd = new LoginUserCommand('user-2', '0987654321', 'ADMIN'); + expect(cmd.role).toBe('ADMIN'); + }); + }); + + describe('RefreshTokenCommand', () => { + it('stores refreshToken string', () => { + const cmd = new RefreshTokenCommand('family.token-hex'); + expect(cmd.refreshToken).toBe('family.token-hex'); + }); + + it('handles long token values', () => { + const longToken = 'a'.repeat(256); + const cmd = new RefreshTokenCommand(longToken); + expect(cmd.refreshToken).toBe(longToken); + }); + }); + + describe('VerifyKycCommand', () => { + it('stores userId and kycStatus', () => { + const cmd = new VerifyKycCommand('user-1', 'APPROVED'); + expect(cmd.userId).toBe('user-1'); + expect(cmd.kycStatus).toBe('APPROVED'); + }); + + it('stores optional kycData', () => { + const kycData = { idNumber: '123456789' }; + const cmd = new VerifyKycCommand('user-1', 'APPROVED', kycData); + expect(cmd.kycData).toEqual(kycData); + }); + + it('kycData is undefined when not provided', () => { + const cmd = new VerifyKycCommand('user-1', 'REJECTED'); + expect(cmd.kycData).toBeUndefined(); + }); + }); + + describe('CancelUserDeletionCommand', () => { + it('stores userId', () => { + const cmd = new CancelUserDeletionCommand('user-1'); + expect(cmd.userId).toBe('user-1'); + }); + }); + + describe('ExportUserDataCommand', () => { + it('stores userId', () => { + const cmd = new ExportUserDataCommand('user-1'); + expect(cmd.userId).toBe('user-1'); + }); + }); + + describe('ForceDeleteUserCommand', () => { + it('stores userId, adminId, and reason', () => { + const cmd = new ForceDeleteUserCommand('user-1', 'admin-1', 'Violation of terms'); + expect(cmd.userId).toBe('user-1'); + expect(cmd.adminId).toBe('admin-1'); + expect(cmd.reason).toBe('Violation of terms'); + }); + + it('preserves Vietnamese text in reason', () => { + const cmd = new ForceDeleteUserCommand('user-2', 'admin-1', 'Vi phạm điều khoản sử dụng'); + expect(cmd.reason).toBe('Vi phạm điều khoản sử dụng'); + }); + }); + + describe('ProcessScheduledDeletionsCommand', () => { + it('is instantiable with no arguments', () => { + const cmd = new ProcessScheduledDeletionsCommand(); + expect(cmd).toBeDefined(); + expect(cmd).toBeInstanceOf(ProcessScheduledDeletionsCommand); + }); + }); + + describe('RequestUserDeletionCommand', () => { + it('stores userId', () => { + const cmd = new RequestUserDeletionCommand('user-1'); + expect(cmd.userId).toBe('user-1'); + }); + + it('stores optional reason', () => { + const cmd = new RequestUserDeletionCommand('user-1', 'No longer needed'); + expect(cmd.reason).toBe('No longer needed'); + }); + + it('reason is undefined when not provided', () => { + const cmd = new RequestUserDeletionCommand('user-1'); + expect(cmd.reason).toBeUndefined(); + }); + }); + + describe('GetProfileQuery', () => { + it('stores userId', () => { + const query = new GetProfileQuery('user-1'); + expect(query.userId).toBe('user-1'); + }); + + it('is an instance of GetProfileQuery', () => { + const query = new GetProfileQuery('user-2'); + expect(query).toBeInstanceOf(GetProfileQuery); + }); + }); + + describe('GetAgentByUserIdQuery', () => { + it('stores userId', () => { + const query = new GetAgentByUserIdQuery('user-1'); + expect(query.userId).toBe('user-1'); + }); + + it('is an instance of GetAgentByUserIdQuery', () => { + const query = new GetAgentByUserIdQuery('user-2'); + expect(query).toBeInstanceOf(GetAgentByUserIdQuery); + }); + }); +}); diff --git a/apps/api/src/modules/auth/domain/__tests__/auth-domain-events.spec.ts b/apps/api/src/modules/auth/domain/__tests__/auth-domain-events.spec.ts new file mode 100644 index 0000000..76eb299 --- /dev/null +++ b/apps/api/src/modules/auth/domain/__tests__/auth-domain-events.spec.ts @@ -0,0 +1,84 @@ +import { describe, it, expect } from 'vitest'; +import { AgentVerifiedEvent } from '../events/agent-verified.event'; +import { UserDeactivatedEvent } from '../events/user-deactivated.event'; +import { UserKycUpdatedEvent } from '../events/user-kyc-updated.event'; +import { UserRegisteredEvent } from '../events/user-registered.event'; + +describe('Auth Domain Events — construction & property access', () => { + describe('UserRegisteredEvent', () => { + it('stores aggregateId, phone and role', () => { + const event = new UserRegisteredEvent('u-1', '+84900000001', 'BUYER'); + expect(event.aggregateId).toBe('u-1'); + expect(event.phone).toBe('+84900000001'); + expect(event.role).toBe('BUYER'); + }); + + it('has eventName "user.registered"', () => { + const event = new UserRegisteredEvent('u-2', '+84900000002', 'AGENT'); + expect(event.eventName).toBe('user.registered'); + }); + + it('sets occurredAt to a recent Date', () => { + const before = new Date(); + const event = new UserRegisteredEvent('u-3', '+84900000003', 'SELLER'); + const after = new Date(); + expect(event.occurredAt.getTime()).toBeGreaterThanOrEqual(before.getTime()); + expect(event.occurredAt.getTime()).toBeLessThanOrEqual(after.getTime()); + }); + }); + + describe('AgentVerifiedEvent', () => { + it('stores aggregateId and userId', () => { + const event = new AgentVerifiedEvent('agent-1', 'user-1'); + expect(event.aggregateId).toBe('agent-1'); + expect(event.userId).toBe('user-1'); + }); + + it('has eventName "agent.verified"', () => { + const event = new AgentVerifiedEvent('agent-2', 'user-2'); + expect(event.eventName).toBe('agent.verified'); + }); + + it('sets occurredAt to a Date instance', () => { + const event = new AgentVerifiedEvent('agent-3', 'user-3'); + expect(event.occurredAt).toBeInstanceOf(Date); + }); + }); + + describe('UserKycUpdatedEvent', () => { + it('stores aggregateId, newStatus, and previousStatus', () => { + const event = new UserKycUpdatedEvent('u-1', 'APPROVED', 'PENDING'); + expect(event.aggregateId).toBe('u-1'); + expect(event.newStatus).toBe('APPROVED'); + expect(event.previousStatus).toBe('PENDING'); + }); + + it('has eventName "user.kyc_updated"', () => { + const event = new UserKycUpdatedEvent('u-2', 'REJECTED', 'APPROVED'); + expect(event.eventName).toBe('user.kyc_updated'); + }); + + it('tracks status transition from NONE to PENDING', () => { + const event = new UserKycUpdatedEvent('u-3', 'PENDING', 'NONE'); + expect(event.newStatus).toBe('PENDING'); + expect(event.previousStatus).toBe('NONE'); + }); + }); + + describe('UserDeactivatedEvent', () => { + it('stores aggregateId', () => { + const event = new UserDeactivatedEvent('u-1'); + expect(event.aggregateId).toBe('u-1'); + }); + + it('has eventName "user.deactivated"', () => { + const event = new UserDeactivatedEvent('u-2'); + expect(event.eventName).toBe('user.deactivated'); + }); + + it('sets occurredAt to a Date instance', () => { + const event = new UserDeactivatedEvent('u-3'); + expect(event.occurredAt).toBeInstanceOf(Date); + }); + }); +}); diff --git a/apps/api/src/modules/auth/infrastructure/__tests__/jwt.strategy.spec.ts b/apps/api/src/modules/auth/infrastructure/__tests__/jwt.strategy.spec.ts new file mode 100644 index 0000000..ca5bc09 --- /dev/null +++ b/apps/api/src/modules/auth/infrastructure/__tests__/jwt.strategy.spec.ts @@ -0,0 +1,77 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; + +// Mock passport-jwt before importing JwtStrategy +vi.mock('passport-jwt', () => { + return { + Strategy: class MockStrategy { + constructor(options: any) { + (this as any)._options = options; + } + }, + ExtractJwt: { + fromAuthHeaderAsBearerToken: () => vi.fn(), + }, + }; +}); + +vi.mock('@nestjs/passport', () => { + return { + PassportStrategy: (StrategyClass: any) => { + return class extends StrategyClass {}; + }, + }; +}); + +describe('JwtStrategy', () => { + afterEach(() => { + vi.unstubAllEnvs(); + vi.resetModules(); + }); + + it('throws if JWT_SECRET is missing', async () => { + vi.stubEnv('JWT_SECRET', ''); + expect(async () => { + const { JwtStrategy } = await import('../strategies/jwt.strategy'); + new JwtStrategy(); + }).rejects.toThrow('JWT_SECRET environment variable is required'); + }); + + it('creates strategy when JWT_SECRET is set', async () => { + vi.stubEnv('JWT_SECRET', 'test-secret-key'); + const { JwtStrategy } = await import('../strategies/jwt.strategy'); + const strategy = new JwtStrategy(); + expect(strategy).toBeDefined(); + }); + + it('validate returns correct payload shape', async () => { + vi.stubEnv('JWT_SECRET', 'test-secret-key'); + const { JwtStrategy } = await import('../strategies/jwt.strategy'); + const strategy = new JwtStrategy(); + + const payload = { sub: 'user-1', phone: '+84912345678', role: 'BUYER', iat: 12345, exp: 99999 }; + const result = strategy.validate(payload); + + expect(result).toEqual({ + sub: 'user-1', + phone: '+84912345678', + role: 'BUYER', + }); + }); + + it('validate strips extra fields from payload', async () => { + vi.stubEnv('JWT_SECRET', 'test-secret-key'); + const { JwtStrategy } = await import('../strategies/jwt.strategy'); + const strategy = new JwtStrategy(); + + const payload = { sub: 'user-2', phone: '+84987654321', role: 'ADMIN', iat: 12345, exp: 99999, extra: 'data' } as any; + const result = strategy.validate(payload); + + expect(result).toEqual({ + sub: 'user-2', + phone: '+84987654321', + role: 'ADMIN', + }); + expect(result).not.toHaveProperty('extra'); + expect(result).not.toHaveProperty('iat'); + }); +}); diff --git a/apps/api/src/modules/auth/infrastructure/__tests__/prisma-refresh-token.repository.spec.ts b/apps/api/src/modules/auth/infrastructure/__tests__/prisma-refresh-token.repository.spec.ts new file mode 100644 index 0000000..6647e44 --- /dev/null +++ b/apps/api/src/modules/auth/infrastructure/__tests__/prisma-refresh-token.repository.spec.ts @@ -0,0 +1,120 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { PrismaRefreshTokenRepository } from '../repositories/prisma-refresh-token.repository'; + +describe('PrismaRefreshTokenRepository', () => { + let repository: PrismaRefreshTokenRepository; + let mockPrisma: { + refreshToken: { + create: ReturnType; + findUnique: ReturnType; + updateMany: ReturnType; + deleteMany: ReturnType; + }; + }; + + const mockTokenRecord = { + id: 'token-1', + userId: 'user-1', + token: 'refresh-token-hash', + family: 'family-uuid', + expiresAt: new Date('2025-01-01'), + revokedAt: null, + createdAt: new Date('2024-01-01'), + }; + + beforeEach(() => { + mockPrisma = { + refreshToken: { + create: vi.fn(), + findUnique: vi.fn(), + updateMany: vi.fn(), + deleteMany: vi.fn(), + }, + }; + repository = new PrismaRefreshTokenRepository(mockPrisma as any); + }); + + describe('create', () => { + it('calls prisma.refreshToken.create with correct data', async () => { + const input = { + userId: 'user-1', + token: 'new-token', + family: 'new-family', + expiresAt: new Date('2025-06-01'), + revokedAt: null, + }; + mockPrisma.refreshToken.create.mockResolvedValue({ ...mockTokenRecord, ...input }); + + const result = await repository.create(input); + + expect(mockPrisma.refreshToken.create).toHaveBeenCalledWith({ + data: { + userId: 'user-1', + token: 'new-token', + family: 'new-family', + expiresAt: input.expiresAt, + revokedAt: null, + }, + }); + expect(result.token).toBe('new-token'); + }); + }); + + describe('findByToken', () => { + it('returns token record when found', async () => { + mockPrisma.refreshToken.findUnique.mockResolvedValue(mockTokenRecord); + const result = await repository.findByToken('refresh-token-hash'); + expect(result).toEqual(mockTokenRecord); + expect(mockPrisma.refreshToken.findUnique).toHaveBeenCalledWith({ + where: { token: 'refresh-token-hash' }, + }); + }); + + it('returns null when token not found', async () => { + mockPrisma.refreshToken.findUnique.mockResolvedValue(null); + const result = await repository.findByToken('non-existent'); + expect(result).toBeNull(); + }); + }); + + describe('revokeByFamily', () => { + it('updates all tokens in family with revokedAt', async () => { + mockPrisma.refreshToken.updateMany.mockResolvedValue({ count: 3 }); + await repository.revokeByFamily('family-uuid'); + + expect(mockPrisma.refreshToken.updateMany).toHaveBeenCalledWith({ + where: { family: 'family-uuid', revokedAt: null }, + data: { revokedAt: expect.any(Date) }, + }); + }); + }); + + describe('revokeAllForUser', () => { + it('updates all tokens for user with revokedAt', async () => { + mockPrisma.refreshToken.updateMany.mockResolvedValue({ count: 5 }); + await repository.revokeAllForUser('user-1'); + + expect(mockPrisma.refreshToken.updateMany).toHaveBeenCalledWith({ + where: { userId: 'user-1', revokedAt: null }, + data: { revokedAt: expect.any(Date) }, + }); + }); + }); + + describe('deleteExpired', () => { + it('deletes expired tokens and returns count', async () => { + mockPrisma.refreshToken.deleteMany.mockResolvedValue({ count: 10 }); + const result = await repository.deleteExpired(); + expect(result).toBe(10); + expect(mockPrisma.refreshToken.deleteMany).toHaveBeenCalledWith({ + where: { expiresAt: { lt: expect.any(Date) } }, + }); + }); + + it('returns 0 when no expired tokens', async () => { + mockPrisma.refreshToken.deleteMany.mockResolvedValue({ count: 0 }); + const result = await repository.deleteExpired(); + expect(result).toBe(0); + }); + }); +}); diff --git a/apps/api/src/modules/auth/infrastructure/__tests__/prisma-user.repository.spec.ts b/apps/api/src/modules/auth/infrastructure/__tests__/prisma-user.repository.spec.ts new file mode 100644 index 0000000..b39acf5 --- /dev/null +++ b/apps/api/src/modules/auth/infrastructure/__tests__/prisma-user.repository.spec.ts @@ -0,0 +1,175 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { PrismaUserRepository } from '../repositories/prisma-user.repository'; + +// Mock domain value objects +vi.mock('../../domain/value-objects/phone.vo', () => ({ + Phone: { + create: vi.fn((value: string) => ({ + isOk: true, + unwrap: () => ({ value: value.startsWith('+84') ? value : '+84' + value.slice(1) }), + })), + }, +})); + +vi.mock('../../domain/value-objects/email.vo', () => ({ + Email: { + create: vi.fn((value: string) => ({ + isOk: true, + unwrap: () => ({ value }), + })), + }, +})); + +vi.mock('../../domain/value-objects/hashed-password.vo', () => ({ + HashedPassword: { + fromHash: vi.fn((hash: string) => ({ value: hash })), + }, +})); + +vi.mock('../../domain/entities/user.entity', () => ({ + UserEntity: class MockUserEntity { + constructor( + public id: string, + public props: any, + public createdAt: Date, + public updatedAt: Date, + ) { + Object.assign(this, props); + } + }, +})); + +describe('PrismaUserRepository', () => { + let repository: PrismaUserRepository; + let mockPrisma: { + user: { + findUnique: ReturnType; + create: ReturnType; + update: ReturnType; + }; + }; + + const mockPrismaUser = { + id: 'user-1', + phone: '+84912345678', + email: 'test@example.com', + passwordHash: '$2b$10$hashedpassword', + fullName: 'Nguyen Van A', + avatarUrl: null, + role: 'BUYER', + kycStatus: 'NONE', + kycData: null, + isActive: true, + createdAt: new Date('2024-01-01'), + updatedAt: new Date('2024-01-02'), + }; + + beforeEach(() => { + mockPrisma = { + user: { + findUnique: vi.fn(), + create: vi.fn(), + update: vi.fn(), + }, + }; + repository = new PrismaUserRepository(mockPrisma as any); + }); + + describe('findById', () => { + it('returns null when user not found', async () => { + mockPrisma.user.findUnique.mockResolvedValue(null); + const result = await repository.findById('non-existent'); + expect(result).toBeNull(); + expect(mockPrisma.user.findUnique).toHaveBeenCalledWith({ where: { id: 'non-existent' } }); + }); + + it('returns domain entity when user is found', async () => { + mockPrisma.user.findUnique.mockResolvedValue(mockPrismaUser); + const result = await repository.findById('user-1'); + expect(result).not.toBeNull(); + expect(result!.id).toBe('user-1'); + }); + }); + + describe('findByPhone', () => { + it('returns null when user not found', async () => { + mockPrisma.user.findUnique.mockResolvedValue(null); + const result = await repository.findByPhone('+84912345678'); + expect(result).toBeNull(); + expect(mockPrisma.user.findUnique).toHaveBeenCalledWith({ where: { phone: '+84912345678' } }); + }); + + it('returns domain entity when user is found', async () => { + mockPrisma.user.findUnique.mockResolvedValue(mockPrismaUser); + const result = await repository.findByPhone('+84912345678'); + expect(result).not.toBeNull(); + }); + }); + + describe('findByEmail', () => { + it('returns null when user not found', async () => { + mockPrisma.user.findUnique.mockResolvedValue(null); + const result = await repository.findByEmail('test@example.com'); + expect(result).toBeNull(); + expect(mockPrisma.user.findUnique).toHaveBeenCalledWith({ where: { email: 'test@example.com' } }); + }); + }); + + describe('save', () => { + it('calls prisma.user.create with correct data', async () => { + const entity = { + id: 'user-1', + email: { value: 'test@example.com' }, + phone: { value: '+84912345678' }, + passwordHash: { value: '$2b$10$hash' }, + fullName: 'Nguyen Van A', + avatarUrl: null, + role: 'BUYER', + kycStatus: 'NONE', + kycData: null, + isActive: true, + }; + + mockPrisma.user.create.mockResolvedValue(undefined); + await repository.save(entity as any); + + expect(mockPrisma.user.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + id: 'user-1', + phone: '+84912345678', + email: 'test@example.com', + role: 'BUYER', + }), + }); + }); + }); + + describe('update', () => { + it('calls prisma.user.update with correct data', async () => { + const entity = { + id: 'user-1', + email: null, + phone: { value: '+84912345678' }, + passwordHash: null, + fullName: 'Updated Name', + avatarUrl: null, + role: 'BUYER', + kycStatus: 'NONE', + kycData: null, + isActive: true, + }; + + mockPrisma.user.update.mockResolvedValue(undefined); + await repository.update(entity as any); + + expect(mockPrisma.user.update).toHaveBeenCalledWith({ + where: { id: 'user-1' }, + data: expect.objectContaining({ + phone: '+84912345678', + email: null, + fullName: 'Updated Name', + }), + }); + }); + }); +}); diff --git a/apps/api/src/modules/auth/presentation/__tests__/current-user.decorator.spec.ts b/apps/api/src/modules/auth/presentation/__tests__/current-user.decorator.spec.ts new file mode 100644 index 0000000..8ec4b1c --- /dev/null +++ b/apps/api/src/modules/auth/presentation/__tests__/current-user.decorator.spec.ts @@ -0,0 +1,22 @@ +import { describe, it, expect } from 'vitest'; +import { CurrentUser } from '../decorators/current-user.decorator'; + +describe('CurrentUser Decorator', () => { + it('should be defined', () => { + expect(CurrentUser).toBeDefined(); + }); + + it('should be a function (parameter decorator factory)', () => { + // createParamDecorator returns a function + expect(typeof CurrentUser).toBe('function'); + }); + + it('extracts user from the execution context', () => { + // The internal factory function receives (_data, ctx) and returns request.user. + // We can access the factory via the decorator's internal metadata. + // For NestJS param decorators, the factory is stored internally. + // We test that the decorator is callable and produces a parameter decorator. + const decorator = CurrentUser(); + expect(typeof decorator).toBe('function'); + }); +}); diff --git a/apps/api/src/modules/auth/presentation/__tests__/google-oauth.guard.spec.ts b/apps/api/src/modules/auth/presentation/__tests__/google-oauth.guard.spec.ts new file mode 100644 index 0000000..4307e90 --- /dev/null +++ b/apps/api/src/modules/auth/presentation/__tests__/google-oauth.guard.spec.ts @@ -0,0 +1,19 @@ +import { describe, it, expect } from 'vitest'; +import { GoogleOAuthGuard } from '../guards/google-oauth.guard'; + +describe('GoogleOAuthGuard', () => { + it('should be instantiable', () => { + const guard = new GoogleOAuthGuard(); + expect(guard).toBeDefined(); + }); + + it('should be an instance of GoogleOAuthGuard', () => { + const guard = new GoogleOAuthGuard(); + expect(guard).toBeInstanceOf(GoogleOAuthGuard); + }); + + it('should have canActivate method', () => { + const guard = new GoogleOAuthGuard(); + expect(typeof guard.canActivate).toBe('function'); + }); +}); diff --git a/apps/api/src/modules/auth/presentation/__tests__/jwt-auth.guard.spec.ts b/apps/api/src/modules/auth/presentation/__tests__/jwt-auth.guard.spec.ts new file mode 100644 index 0000000..ef597c9 --- /dev/null +++ b/apps/api/src/modules/auth/presentation/__tests__/jwt-auth.guard.spec.ts @@ -0,0 +1,19 @@ +import { describe, it, expect } from 'vitest'; +import { JwtAuthGuard } from '../guards/jwt-auth.guard'; + +describe('JwtAuthGuard', () => { + it('should be instantiable', () => { + const guard = new JwtAuthGuard(); + expect(guard).toBeDefined(); + }); + + it('should be an instance of JwtAuthGuard', () => { + const guard = new JwtAuthGuard(); + expect(guard).toBeInstanceOf(JwtAuthGuard); + }); + + it('should have canActivate method', () => { + const guard = new JwtAuthGuard(); + expect(typeof guard.canActivate).toBe('function'); + }); +}); diff --git a/apps/api/src/modules/auth/presentation/__tests__/local-auth.guard.spec.ts b/apps/api/src/modules/auth/presentation/__tests__/local-auth.guard.spec.ts new file mode 100644 index 0000000..e71353c --- /dev/null +++ b/apps/api/src/modules/auth/presentation/__tests__/local-auth.guard.spec.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from 'vitest'; +import { LocalAuthGuard } from '../guards/local-auth.guard'; + +describe('LocalAuthGuard', () => { + let guard: LocalAuthGuard; + + beforeEach(() => { + guard = new LocalAuthGuard(); + }); + + it('returns user when user is provided', () => { + const user = { id: 'user-1', phone: '+84912345678', role: 'BUYER' }; + const result = guard.handleRequest(null, user, undefined, {} as any); + expect(result).toEqual(user); + }); + + it('throws error when error is provided', () => { + const error = new Error('Strategy error'); + expect(() => guard.handleRequest(error, null, undefined, {} as any)).toThrow('Strategy error'); + }); + + it('throws UnauthorizedException when no user and no error', () => { + expect(() => guard.handleRequest(null, null as any, undefined, {} as any)).toThrow( + 'Số điện thoại hoặc mật khẩu không đúng', + ); + }); + + it('re-throws the original error type', () => { + const customError = new TypeError('Custom type error'); + expect(() => guard.handleRequest(customError, null, undefined, {} as any)).toThrow(TypeError); + }); +}); diff --git a/apps/api/src/modules/auth/presentation/__tests__/login.dto.spec.ts b/apps/api/src/modules/auth/presentation/__tests__/login.dto.spec.ts new file mode 100644 index 0000000..f98d9ae --- /dev/null +++ b/apps/api/src/modules/auth/presentation/__tests__/login.dto.spec.ts @@ -0,0 +1,41 @@ +import { validate } from 'class-validator'; +import { describe, it, expect } from 'vitest'; +import { LoginDto } from '../dto/login.dto'; + +describe('LoginDto', () => { + it('accepts valid login data', async () => { + const dto = new LoginDto(); + dto.phone = '0901234567'; + dto.password = 'P@ssw0rd!'; + + const errors = await validate(dto); + expect(errors).toHaveLength(0); + }); + + it('rejects missing phone', async () => { + const dto = new LoginDto(); + dto.password = 'P@ssw0rd!'; + + const errors = await validate(dto); + expect(errors.length).toBeGreaterThan(0); + expect(errors.some((e) => e.property === 'phone')).toBe(true); + }); + + it('rejects missing password', async () => { + const dto = new LoginDto(); + dto.phone = '0901234567'; + + const errors = await validate(dto); + expect(errors.length).toBeGreaterThan(0); + expect(errors.some((e) => e.property === 'password')).toBe(true); + }); + + it('rejects non-string phone', async () => { + const dto = new LoginDto(); + (dto as any).phone = 12345; + dto.password = 'P@ssw0rd!'; + + const errors = await validate(dto); + expect(errors.length).toBeGreaterThan(0); + }); +}); diff --git a/apps/api/src/modules/auth/presentation/__tests__/register.dto.spec.ts b/apps/api/src/modules/auth/presentation/__tests__/register.dto.spec.ts new file mode 100644 index 0000000..7f57a9d --- /dev/null +++ b/apps/api/src/modules/auth/presentation/__tests__/register.dto.spec.ts @@ -0,0 +1,65 @@ +import { validate } from 'class-validator'; +import { describe, it, expect } from 'vitest'; +import { RegisterDto } from '../dto/register.dto'; + +describe('RegisterDto', () => { + const createValidDto = (): RegisterDto => { + const dto = new RegisterDto(); + dto.phone = '0901234567'; + dto.password = 'P@ssw0rd!'; + dto.fullName = 'Nguyen Van A'; + return dto; + }; + + it('accepts valid registration data', async () => { + const dto = createValidDto(); + const errors = await validate(dto); + expect(errors).toHaveLength(0); + }); + + it('accepts valid data with optional email', async () => { + const dto = createValidDto(); + dto.email = 'user@example.com'; + const errors = await validate(dto); + expect(errors).toHaveLength(0); + }); + + it('rejects missing phone', async () => { + const dto = createValidDto(); + delete (dto as any).phone; + const errors = await validate(dto); + expect(errors.length).toBeGreaterThan(0); + expect(errors.some((e) => e.property === 'phone')).toBe(true); + }); + + it('rejects password shorter than 8 characters', async () => { + const dto = createValidDto(); + dto.password = 'short'; + const errors = await validate(dto); + expect(errors.length).toBeGreaterThan(0); + expect(errors.some((e) => e.property === 'password')).toBe(true); + }); + + it('rejects empty fullName', async () => { + const dto = createValidDto(); + dto.fullName = ''; + const errors = await validate(dto); + expect(errors.length).toBeGreaterThan(0); + expect(errors.some((e) => e.property === 'fullName')).toBe(true); + }); + + it('rejects invalid email format', async () => { + const dto = createValidDto(); + dto.email = 'not-an-email'; + const errors = await validate(dto); + expect(errors.length).toBeGreaterThan(0); + expect(errors.some((e) => e.property === 'email')).toBe(true); + }); + + it('accepts dto without email (optional field)', async () => { + const dto = createValidDto(); + // email not set + const errors = await validate(dto); + expect(errors).toHaveLength(0); + }); +}); diff --git a/apps/api/src/modules/auth/presentation/__tests__/roles.decorator.spec.ts b/apps/api/src/modules/auth/presentation/__tests__/roles.decorator.spec.ts new file mode 100644 index 0000000..203690f --- /dev/null +++ b/apps/api/src/modules/auth/presentation/__tests__/roles.decorator.spec.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from 'vitest'; +import { Roles, ROLES_KEY } from '../decorators/roles.decorator'; + +describe('Roles Decorator', () => { + it('exports ROLES_KEY constant', () => { + expect(ROLES_KEY).toBe('roles'); + }); + + it('returns a decorator function', () => { + const decorator = Roles('ADMIN' as any); + expect(typeof decorator).toBe('function'); + }); + + it('applies metadata with single role', () => { + const decorator = Roles('ADMIN' as any); + // SetMetadata returns a decorator that sets Reflect metadata + // We test by applying it to a test class + @decorator + class TestClass {} + + const metadata = Reflect.getMetadata(ROLES_KEY, TestClass); + expect(metadata).toEqual(['ADMIN']); + }); + + it('applies metadata with multiple roles', () => { + const decorator = Roles('ADMIN' as any, 'MODERATOR' as any); + + @decorator + class TestClass {} + + const metadata = Reflect.getMetadata(ROLES_KEY, TestClass); + expect(metadata).toEqual(['ADMIN', 'MODERATOR']); + }); + + it('applies metadata with no roles', () => { + const decorator = Roles(); + + @decorator + class TestClass {} + + const metadata = Reflect.getMetadata(ROLES_KEY, TestClass); + expect(metadata).toEqual([]); + }); +}); diff --git a/apps/api/src/modules/auth/presentation/__tests__/roles.guard.spec.ts b/apps/api/src/modules/auth/presentation/__tests__/roles.guard.spec.ts new file mode 100644 index 0000000..925da64 --- /dev/null +++ b/apps/api/src/modules/auth/presentation/__tests__/roles.guard.spec.ts @@ -0,0 +1,84 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { ROLES_KEY } from '../decorators/roles.decorator'; +import { RolesGuard } from '../guards/roles.guard'; + +describe('RolesGuard', () => { + let guard: RolesGuard; + let mockReflector: { getAllAndOverride: ReturnType }; + let mockLogger: { warn: ReturnType }; + + const createMockContext = (user?: { sub: string; role: string }) => { + const mockRequest = { + user, + ip: '127.0.0.1', + headers: {}, + }; + return { + switchToHttp: () => ({ + getRequest: () => mockRequest, + }), + getHandler: () => ({ name: 'testHandler' }), + getClass: () => ({ name: 'TestController' }), + } as any; + }; + + beforeEach(() => { + mockReflector = { getAllAndOverride: vi.fn() }; + mockLogger = { warn: vi.fn() }; + guard = new RolesGuard(mockReflector as any, mockLogger as any); + }); + + it('returns true when no roles are required', () => { + mockReflector.getAllAndOverride.mockReturnValue(undefined); + const context = createMockContext({ sub: 'user-1', role: 'BUYER' }); + expect(guard.canActivate(context)).toBe(true); + }); + + it('returns true when empty roles array is required', () => { + mockReflector.getAllAndOverride.mockReturnValue([]); + const context = createMockContext({ sub: 'user-1', role: 'BUYER' }); + expect(guard.canActivate(context)).toBe(true); + }); + + it('returns true when user has matching role', () => { + mockReflector.getAllAndOverride.mockReturnValue(['ADMIN', 'MODERATOR']); + const context = createMockContext({ sub: 'admin-1', role: 'ADMIN' }); + expect(guard.canActivate(context)).toBe(true); + }); + + it('returns false when user has non-matching role', () => { + mockReflector.getAllAndOverride.mockReturnValue(['ADMIN']); + const context = createMockContext({ sub: 'user-1', role: 'BUYER' }); + expect(guard.canActivate(context)).toBe(false); + }); + + it('returns false when no user on request', () => { + mockReflector.getAllAndOverride.mockReturnValue(['ADMIN']); + const context = createMockContext(undefined); + expect(guard.canActivate(context)).toBe(false); + }); + + it('logs warning when access is denied', () => { + mockReflector.getAllAndOverride.mockReturnValue(['ADMIN']); + const context = createMockContext({ sub: 'user-1', role: 'BUYER' }); + guard.canActivate(context); + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('Access denied'), + 'RolesGuard', + ); + }); + + it('reads ROLES_KEY metadata from handler and class', () => { + mockReflector.getAllAndOverride.mockReturnValue(undefined); + const context = createMockContext({ sub: 'user-1', role: 'BUYER' }); + guard.canActivate(context); + + expect(mockReflector.getAllAndOverride).toHaveBeenCalledWith( + ROLES_KEY, + [ + expect.objectContaining({ name: 'testHandler' }), + expect.objectContaining({ name: 'TestController' }), + ], + ); + }); +}); diff --git a/apps/api/src/modules/auth/presentation/__tests__/verify-kyc.dto.spec.ts b/apps/api/src/modules/auth/presentation/__tests__/verify-kyc.dto.spec.ts new file mode 100644 index 0000000..b00109b --- /dev/null +++ b/apps/api/src/modules/auth/presentation/__tests__/verify-kyc.dto.spec.ts @@ -0,0 +1,51 @@ +import { validate } from 'class-validator'; +import { describe, it, expect } from 'vitest'; +import { VerifyKycDto } from '../dto/verify-kyc.dto'; + +describe('VerifyKycDto', () => { + it('accepts valid KYC data with VERIFIED status', async () => { + const dto = new VerifyKycDto(); + dto.kycStatus = 'VERIFIED' as any; + const errors = await validate(dto); + expect(errors).toHaveLength(0); + }); + + it('accepts valid KYC data with REJECTED status', async () => { + const dto = new VerifyKycDto(); + dto.kycStatus = 'REJECTED' as any; + const errors = await validate(dto); + expect(errors).toHaveLength(0); + }); + + it('accepts KYC data with optional kycData object', async () => { + const dto = new VerifyKycDto(); + dto.kycStatus = 'VERIFIED' as any; + dto.kycData = { idNumber: '123456789', issueDate: '2024-01-01' }; + const errors = await validate(dto); + expect(errors).toHaveLength(0); + }); + + it('rejects invalid kycStatus enum value', async () => { + const dto = new VerifyKycDto(); + dto.kycStatus = 'INVALID_STATUS' as any; + const errors = await validate(dto); + expect(errors.length).toBeGreaterThan(0); + expect(errors.some((e) => e.property === 'kycStatus')).toBe(true); + }); + + it('rejects missing kycStatus', async () => { + const dto = new VerifyKycDto(); + const errors = await validate(dto); + expect(errors.length).toBeGreaterThan(0); + expect(errors.some((e) => e.property === 'kycStatus')).toBe(true); + }); + + it('rejects non-object kycData', async () => { + const dto = new VerifyKycDto(); + dto.kycStatus = 'VERIFIED' as any; + (dto as any).kycData = 'not-an-object'; + const errors = await validate(dto); + expect(errors.length).toBeGreaterThan(0); + expect(errors.some((e) => e.property === 'kycData')).toBe(true); + }); +}); diff --git a/apps/api/src/modules/listings/application/__tests__/commands.spec.ts b/apps/api/src/modules/listings/application/__tests__/commands.spec.ts new file mode 100644 index 0000000..da4aa2f --- /dev/null +++ b/apps/api/src/modules/listings/application/__tests__/commands.spec.ts @@ -0,0 +1,205 @@ +import { describe, it, expect } from 'vitest'; +import { CreateListingCommand } from '../commands/create-listing/create-listing.command'; +import { ModerateListingCommand } from '../commands/moderate-listing/moderate-listing.command'; +import { UpdateListingStatusCommand } from '../commands/update-listing-status/update-listing-status.command'; +import { UploadMediaCommand } from '../commands/upload-media/upload-media.command'; + +describe('CreateListingCommand', () => { + it('should store all required properties', () => { + const command = new CreateListingCommand( + 'seller-1', + 'SALE', + 5_000_000_000n, + 'APARTMENT', + 'Căn hộ đẹp Quận 1', + 'Mô tả chi tiết', + '123 Nguyễn Huệ', + 'Bến Nghé', + 'Quận 1', + 'Hồ Chí Minh', + 10.7769, + 106.7009, + 80, + ); + + expect(command.sellerId).toBe('seller-1'); + expect(command.transactionType).toBe('SALE'); + expect(command.priceVND).toBe(5_000_000_000n); + expect(command.propertyType).toBe('APARTMENT'); + expect(command.title).toBe('Căn hộ đẹp Quận 1'); + expect(command.description).toBe('Mô tả chi tiết'); + expect(command.address).toBe('123 Nguyễn Huệ'); + expect(command.ward).toBe('Bến Nghé'); + expect(command.district).toBe('Quận 1'); + expect(command.city).toBe('Hồ Chí Minh'); + expect(command.latitude).toBe(10.7769); + expect(command.longitude).toBe(106.7009); + expect(command.areaM2).toBe(80); + }); + + it('should store optional properties', () => { + const command = new CreateListingCommand( + 'seller-1', + 'RENT', + 3_000_000_000n, + 'TOWNHOUSE', + 'Nhà phố cho thuê', + 'Nhà phố 3 tầng mặt tiền rộng', + '456 Lê Lợi', + 'Phường 1', + 'Quận 3', + 'Hồ Chí Minh', + 10.78, + 106.69, + 120, + 100, // usableAreaM2 + 3, // bedrooms + 2, // bathrooms + 3, // floors + undefined, // floor + undefined, // totalFloors + 'EAST', // direction + 2020, // yearBuilt + 'Sổ hồng', // legalStatus + { parking: true }, // amenities + [], // nearbyPOIs + 500, // metroDistanceM + undefined, // projectName + 'agent-1', // agentId + 25_000_000n, // rentPriceMonthly + 2.5, // commissionPct + ); + + expect(command.usableAreaM2).toBe(100); + expect(command.bedrooms).toBe(3); + expect(command.bathrooms).toBe(2); + expect(command.floors).toBe(3); + expect(command.direction).toBe('EAST'); + expect(command.yearBuilt).toBe(2020); + expect(command.legalStatus).toBe('Sổ hồng'); + expect(command.agentId).toBe('agent-1'); + expect(command.rentPriceMonthly).toBe(25_000_000n); + expect(command.commissionPct).toBe(2.5); + }); + + it('should default optional properties to undefined', () => { + const command = new CreateListingCommand( + 'seller-1', 'SALE', 1_000_000_000n, + 'LAND', 'Đất nền', 'Mô tả', + '789 ABC', 'Ward', 'District', 'City', + 10.0, 106.0, 200, + ); + + expect(command.usableAreaM2).toBeUndefined(); + expect(command.bedrooms).toBeUndefined(); + expect(command.agentId).toBeUndefined(); + expect(command.rentPriceMonthly).toBeUndefined(); + }); +}); + +describe('ModerateListingCommand', () => { + it('should store all properties for approve', () => { + const command = new ModerateListingCommand( + 'listing-1', + 'admin-1', + 'approve', + 95, + 'Tin đăng hợp lệ', + ); + + expect(command.listingId).toBe('listing-1'); + expect(command.moderatorId).toBe('admin-1'); + expect(command.action).toBe('approve'); + expect(command.moderationScore).toBe(95); + expect(command.notes).toBe('Tin đăng hợp lệ'); + }); + + it('should store properties for reject without score', () => { + const command = new ModerateListingCommand( + 'listing-2', + 'admin-1', + 'reject', + undefined, + 'Nội dung vi phạm', + ); + + expect(command.action).toBe('reject'); + expect(command.moderationScore).toBeUndefined(); + expect(command.notes).toBe('Nội dung vi phạm'); + }); + + it('should default optional fields to undefined', () => { + const command = new ModerateListingCommand( + 'listing-3', + 'admin-1', + 'approve', + ); + + expect(command.moderationScore).toBeUndefined(); + expect(command.notes).toBeUndefined(); + }); +}); + +describe('UpdateListingStatusCommand', () => { + it('should store all properties', () => { + const command = new UpdateListingStatusCommand( + 'listing-1', + 'ACTIVE', + 'user-1', + 'Đã xác minh thông tin', + ); + + expect(command.listingId).toBe('listing-1'); + expect(command.newStatus).toBe('ACTIVE'); + expect(command.userId).toBe('user-1'); + expect(command.moderationNotes).toBe('Đã xác minh thông tin'); + }); + + it('should default moderationNotes to undefined', () => { + const command = new UpdateListingStatusCommand( + 'listing-2', + 'SOLD', + 'user-2', + ); + + expect(command.moderationNotes).toBeUndefined(); + }); +}); + +describe('UploadMediaCommand', () => { + it('should store all properties', () => { + const file = { + buffer: Buffer.from('test-image-data'), + mimetype: 'image/jpeg', + originalname: 'photo.jpg', + size: 1024, + }; + + const command = new UploadMediaCommand( + 'prop-1', + 'user-1', + file, + 'Mặt tiền nhà', + ); + + expect(command.propertyId).toBe('prop-1'); + expect(command.userId).toBe('user-1'); + expect(command.file.mimetype).toBe('image/jpeg'); + expect(command.file.originalname).toBe('photo.jpg'); + expect(command.file.size).toBe(1024); + expect(command.caption).toBe('Mặt tiền nhà'); + }); + + it('should default caption to undefined', () => { + const file = { + buffer: Buffer.from('test-video-data'), + mimetype: 'video/mp4', + originalname: 'tour.mp4', + size: 50_000_000, + }; + + const command = new UploadMediaCommand('prop-2', 'user-2', file); + + expect(command.caption).toBeUndefined(); + }); +}); diff --git a/apps/api/src/modules/listings/application/__tests__/queries.spec.ts b/apps/api/src/modules/listings/application/__tests__/queries.spec.ts new file mode 100644 index 0000000..7aaffc7 --- /dev/null +++ b/apps/api/src/modules/listings/application/__tests__/queries.spec.ts @@ -0,0 +1,103 @@ +import { describe, it, expect } from 'vitest'; +import { GetListingQuery } from '../queries/get-listing/get-listing.query'; +import { GetPendingModerationQuery } from '../queries/get-pending-moderation/get-pending-moderation.query'; +import { SearchListingsQuery } from '../queries/search-listings/search-listings.query'; + +describe('GetListingQuery', () => { + it('should store listingId', () => { + const query = new GetListingQuery('listing-1'); + expect(query.listingId).toBe('listing-1'); + }); + + it('should store different listing IDs', () => { + const query1 = new GetListingQuery('a1b2c3d4'); + const query2 = new GetListingQuery('e5f6g7h8'); + + expect(query1.listingId).toBe('a1b2c3d4'); + expect(query2.listingId).toBe('e5f6g7h8'); + expect(query1.listingId).not.toBe(query2.listingId); + }); +}); + +describe('GetPendingModerationQuery', () => { + it('should use default page and limit', () => { + const query = new GetPendingModerationQuery(); + expect(query.page).toBe(1); + expect(query.limit).toBe(20); + }); + + it('should accept custom page and limit', () => { + const query = new GetPendingModerationQuery(3, 50); + expect(query.page).toBe(3); + expect(query.limit).toBe(50); + }); + + it('should accept custom page with default limit', () => { + const query = new GetPendingModerationQuery(2); + expect(query.page).toBe(2); + expect(query.limit).toBe(20); + }); +}); + +describe('SearchListingsQuery', () => { + it('should use defaults when no params provided', () => { + const query = new SearchListingsQuery(); + expect(query.status).toBeUndefined(); + expect(query.transactionType).toBeUndefined(); + expect(query.propertyType).toBeUndefined(); + expect(query.city).toBeUndefined(); + expect(query.district).toBeUndefined(); + expect(query.minPrice).toBeUndefined(); + expect(query.maxPrice).toBeUndefined(); + expect(query.minArea).toBeUndefined(); + expect(query.maxArea).toBeUndefined(); + expect(query.bedrooms).toBeUndefined(); + expect(query.page).toBe(1); + expect(query.limit).toBe(20); + }); + + it('should store all filter parameters', () => { + const query = new SearchListingsQuery( + 'ACTIVE', + 'SALE', + 'APARTMENT', + 'Hồ Chí Minh', + 'Quận 1', + 2_000_000_000n, + 10_000_000_000n, + 50, + 200, + 2, + 1, + 20, + ); + + expect(query.status).toBe('ACTIVE'); + expect(query.transactionType).toBe('SALE'); + expect(query.propertyType).toBe('APARTMENT'); + expect(query.city).toBe('Hồ Chí Minh'); + expect(query.district).toBe('Quận 1'); + expect(query.minPrice).toBe(2_000_000_000n); + expect(query.maxPrice).toBe(10_000_000_000n); + expect(query.minArea).toBe(50); + expect(query.maxArea).toBe(200); + expect(query.bedrooms).toBe(2); + expect(query.page).toBe(1); + expect(query.limit).toBe(20); + }); + + it('should allow partial filter parameters', () => { + const query = new SearchListingsQuery( + 'ACTIVE', + undefined, + 'VILLA', + 'Hồ Chí Minh', + ); + + expect(query.status).toBe('ACTIVE'); + expect(query.transactionType).toBeUndefined(); + expect(query.propertyType).toBe('VILLA'); + expect(query.city).toBe('Hồ Chí Minh'); + expect(query.district).toBeUndefined(); + }); +}); diff --git a/apps/api/src/modules/listings/domain/__tests__/moderation.service.spec.ts b/apps/api/src/modules/listings/domain/__tests__/moderation.service.spec.ts new file mode 100644 index 0000000..79ccbe8 --- /dev/null +++ b/apps/api/src/modules/listings/domain/__tests__/moderation.service.spec.ts @@ -0,0 +1,131 @@ +import { describe, it, expect } from 'vitest'; +import { ListingEntity } from '../entities/listing.entity'; +import { ModerationService } from '../services/moderation.service'; +import { Price } from '../value-objects/price.vo'; + +describe('ModerationService', () => { + const service = new ModerationService(); + + const makeListingInReview = () => { + const price = Price.create(5_000_000_000n).unwrap(); + const listing = ListingEntity.createNew( + 'listing-1', + 'property-1', + 'seller-1', + 'SALE', + price, + 100, + 'agent-1', + ); + listing.submitForReview(); + listing.clearDomainEvents(); + return listing; + }; + + describe('applyModeration', () => { + it('should approve a listing', () => { + const listing = makeListingInReview(); + + service.applyModeration(listing, { action: 'approve' }); + + expect(listing.status).toBe('ACTIVE'); + expect(listing.publishedAt).toBeTruthy(); + }); + + it('should reject a listing with default notes', () => { + const listing = makeListingInReview(); + + service.applyModeration(listing, { action: 'reject' }); + + expect(listing.status).toBe('REJECTED'); + expect(listing.moderationNotes).toBe('Bị từ chối bởi moderator'); + }); + + it('should reject a listing with custom notes', () => { + const listing = makeListingInReview(); + + service.applyModeration(listing, { + action: 'reject', + notes: 'Ảnh không rõ ràng', + }); + + expect(listing.status).toBe('REJECTED'); + expect(listing.moderationNotes).toBe('Ảnh không rõ ràng'); + }); + + it('should set moderation score when provided on approve', () => { + const listing = makeListingInReview(); + + service.applyModeration(listing, { + action: 'approve', + moderationScore: 95, + notes: 'Tin đăng chất lượng', + }); + + expect(listing.status).toBe('ACTIVE'); + expect(listing.moderationScore).toBe(95); + expect(listing.moderationNotes).toBe('Tin đăng chất lượng'); + }); + + it('should set moderation score when provided on reject', () => { + const listing = makeListingInReview(); + + service.applyModeration(listing, { + action: 'reject', + moderationScore: 20, + notes: 'Nội dung vi phạm', + }); + + expect(listing.status).toBe('REJECTED'); + expect(listing.moderationScore).toBe(20); + }); + }); + + describe('applyStatusTransition', () => { + it('should reject with moderation notes', () => { + const listing = makeListingInReview(); + + service.applyStatusTransition(listing, 'REJECTED', 'Vi phạm chính sách'); + + expect(listing.status).toBe('REJECTED'); + expect(listing.moderationNotes).toBe('Vi phạm chính sách'); + }); + + it('should approve a PENDING_REVIEW listing transitioning to ACTIVE', () => { + const listing = makeListingInReview(); + + service.applyStatusTransition(listing, 'ACTIVE'); + + expect(listing.status).toBe('ACTIVE'); + expect(listing.publishedAt).toBeTruthy(); + }); + + it('should use generic transitionTo for non-moderation transitions', () => { + const price = Price.create(5_000_000_000n).unwrap(); + const listing = ListingEntity.createNew( + 'listing-2', + 'property-2', + 'seller-2', + 'SALE', + price, + 100, + ); + listing.submitForReview(); + listing.approve(); + listing.clearDomainEvents(); + + service.applyStatusTransition(listing, 'SOLD'); + + expect(listing.status).toBe('SOLD'); + }); + + it('should use generic transitionTo for REJECTED without notes', () => { + const listing = makeListingInReview(); + + // REJECTED without moderationNotes falls into the generic transitionTo branch + service.applyStatusTransition(listing, 'REJECTED'); + + expect(listing.status).toBe('REJECTED'); + }); + }); +}); diff --git a/apps/api/src/modules/listings/domain/__tests__/property-media.entity.spec.ts b/apps/api/src/modules/listings/domain/__tests__/property-media.entity.spec.ts new file mode 100644 index 0000000..f5899ec --- /dev/null +++ b/apps/api/src/modules/listings/domain/__tests__/property-media.entity.spec.ts @@ -0,0 +1,83 @@ +import { describe, it, expect } from 'vitest'; +import { PropertyMediaEntity } from '../entities/property-media.entity'; + +describe('PropertyMediaEntity', () => { + it('should create media via constructor with all properties', () => { + const media = new PropertyMediaEntity('media-1', { + propertyId: 'prop-1', + url: 'https://cdn.example.com/photo.jpg', + type: 'image', + order: 0, + caption: 'Mặt tiền nhà', + aiTags: { tags: ['exterior'] }, + }); + + expect(media.id).toBe('media-1'); + expect(media.propertyId).toBe('prop-1'); + expect(media.url).toBe('https://cdn.example.com/photo.jpg'); + expect(media.type).toBe('image'); + expect(media.order).toBe(0); + expect(media.caption).toBe('Mặt tiền nhà'); + expect(media.aiTags).toEqual({ tags: ['exterior'] }); + expect(media.createdAt).toBeInstanceOf(Date); + }); + + it('should create media via createNew factory with caption', () => { + const media = PropertyMediaEntity.createNew( + 'media-2', + 'prop-1', + 'https://cdn.example.com/interior.jpg', + 'image', + 1, + 'Phòng khách', + ); + + expect(media.id).toBe('media-2'); + expect(media.propertyId).toBe('prop-1'); + expect(media.url).toBe('https://cdn.example.com/interior.jpg'); + expect(media.type).toBe('image'); + expect(media.order).toBe(1); + expect(media.caption).toBe('Phòng khách'); + expect(media.aiTags).toBeNull(); + }); + + it('should create media via createNew factory without caption', () => { + const media = PropertyMediaEntity.createNew( + 'media-3', + 'prop-2', + 'https://cdn.example.com/video.mp4', + 'video', + 0, + ); + + expect(media.type).toBe('video'); + expect(media.caption).toBeNull(); + expect(media.aiTags).toBeNull(); + }); + + it('should preserve order values', () => { + const media0 = PropertyMediaEntity.createNew('m-0', 'p-1', 'http://a.com/0.jpg', 'image', 0); + const media5 = PropertyMediaEntity.createNew('m-5', 'p-1', 'http://a.com/5.jpg', 'image', 5); + + expect(media0.order).toBe(0); + expect(media5.order).toBe(5); + }); + + it('should accept custom createdAt via constructor', () => { + const pastDate = new Date('2024-01-01T00:00:00Z'); + const media = new PropertyMediaEntity( + 'media-old', + { + propertyId: 'prop-1', + url: 'https://cdn.example.com/old.jpg', + type: 'image', + order: 0, + caption: null, + aiTags: null, + }, + pastDate, + ); + + expect(media.createdAt).toBe(pastDate); + }); +}); diff --git a/apps/api/src/modules/listings/infrastructure/__tests__/listing-read.queries.spec.ts b/apps/api/src/modules/listings/infrastructure/__tests__/listing-read.queries.spec.ts new file mode 100644 index 0000000..c0d6a95 --- /dev/null +++ b/apps/api/src/modules/listings/infrastructure/__tests__/listing-read.queries.spec.ts @@ -0,0 +1,198 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { findByIdWithProperty, searchListings, findBySellerIdQuery } from '../repositories/listing-read.queries'; + +describe('listing-read.queries', () => { + let mockPrisma: { + listing: { + findUnique: ReturnType; + findMany: ReturnType; + count: ReturnType; + }; + }; + + beforeEach(() => { + mockPrisma = { + listing: { + findUnique: vi.fn(), + findMany: vi.fn().mockResolvedValue([]), + count: vi.fn().mockResolvedValue(0), + }, + }; + }); + + describe('findByIdWithProperty', () => { + it('should return null when listing not found', async () => { + mockPrisma.listing.findUnique.mockResolvedValue(null); + + const result = await findByIdWithProperty(mockPrisma as any, 'non-existent'); + + expect(result).toBeNull(); + }); + + it('should return mapped ListingDetailData when listing is found', async () => { + const now = new Date(); + mockPrisma.listing.findUnique.mockResolvedValue({ + id: 'listing-1', + status: 'ACTIVE', + transactionType: 'SALE', + priceVND: 5_000_000_000n, + pricePerM2: 62_500_000, + rentPriceMonthly: null, + commissionPct: 2.0, + viewCount: 10, + saveCount: 2, + inquiryCount: 1, + publishedAt: now, + createdAt: now, + property: { + id: 'prop-1', + propertyType: 'APARTMENT', + title: 'Căn hộ đẹp', + description: 'Mô tả', + address: '123 Nguyễn Huệ', + ward: 'Bến Nghé', + district: 'Quận 1', + city: 'Hồ Chí Minh', + areaM2: 80, + bedrooms: 2, + bathrooms: 2, + floors: null, + direction: 'EAST', + yearBuilt: 2022, + legalStatus: 'Sổ hồng', + amenities: null, + projectName: 'Vinhomes', + media: [ + { id: 'm-1', url: 'https://cdn.example.com/1.jpg', type: 'image', order: 0, caption: null }, + ], + }, + seller: { id: 'seller-1', fullName: 'Nguyễn Văn A', phone: '0901234567' }, + agent: { id: 'agent-1', userId: 'user-a', agency: 'Đất Xanh' }, + }); + + const result = await findByIdWithProperty(mockPrisma as any, 'listing-1'); + + expect(result).not.toBeNull(); + expect(result!.id).toBe('listing-1'); + expect(result!.status).toBe('ACTIVE'); + expect(result!.priceVND).toBe('5000000000'); + expect(result!.property.title).toBe('Căn hộ đẹp'); + expect(result!.property.media).toHaveLength(1); + expect(result!.seller.fullName).toBe('Nguyễn Văn A'); + expect(result!.agent!.agency).toBe('Đất Xanh'); + expect(result!.publishedAt).toBe(now.toISOString()); + }); + }); + + describe('searchListings', () => { + it('should return paginated results with empty data', async () => { + mockPrisma.listing.findMany.mockResolvedValue([]); + mockPrisma.listing.count.mockResolvedValue(0); + + const result = await searchListings(mockPrisma as any, { page: 1, limit: 20 }); + + expect(result.data).toEqual([]); + expect(result.total).toBe(0); + expect(result.page).toBe(1); + expect(result.limit).toBe(20); + expect(result.totalPages).toBe(0); + }); + + it('should map listing data correctly', async () => { + const now = new Date(); + mockPrisma.listing.findMany.mockResolvedValue([ + { + id: 'listing-1', + status: 'ACTIVE', + transactionType: 'SALE', + priceVND: 5_000_000_000n, + pricePerM2: 62_500_000, + viewCount: 5, + publishedAt: now, + property: { + id: 'prop-1', + propertyType: 'APARTMENT', + title: 'Căn hộ đẹp', + address: '123 Nguyễn Huệ', + district: 'Quận 1', + city: 'Hồ Chí Minh', + areaM2: 80, + bedrooms: 2, + bathrooms: 2, + media: [{ url: 'https://cdn.example.com/thumb.jpg' }], + }, + seller: { id: 'seller-1', fullName: 'Nguyễn Văn A' }, + }, + ]); + mockPrisma.listing.count.mockResolvedValue(1); + + const result = await searchListings(mockPrisma as any, { status: 'ACTIVE', page: 1, limit: 20 }); + + expect(result.data).toHaveLength(1); + expect(result.data[0]!.id).toBe('listing-1'); + expect(result.data[0]!.priceVND).toBe('5000000000'); + expect(result.data[0]!.property.thumbnail).toBe('https://cdn.example.com/thumb.jpg'); + expect(result.total).toBe(1); + expect(result.totalPages).toBe(1); + }); + + it('should cap limit at 100', async () => { + mockPrisma.listing.findMany.mockResolvedValue([]); + mockPrisma.listing.count.mockResolvedValue(0); + + const result = await searchListings(mockPrisma as any, { limit: 500 }); + + expect(result.limit).toBe(100); + }); + + it('should default page to 1 and limit to 20', async () => { + mockPrisma.listing.findMany.mockResolvedValue([]); + mockPrisma.listing.count.mockResolvedValue(0); + + const result = await searchListings(mockPrisma as any, {}); + + expect(result.page).toBe(1); + expect(result.limit).toBe(20); + }); + }); + + describe('findBySellerIdQuery', () => { + it('should return paginated seller listings', async () => { + mockPrisma.listing.findMany.mockResolvedValue([]); + mockPrisma.listing.count.mockResolvedValue(0); + + const result = await findBySellerIdQuery(mockPrisma as any, 'seller-1', 1, 10); + + expect(result.data).toEqual([]); + expect(result.total).toBe(0); + expect(result.page).toBe(1); + expect(result.limit).toBe(10); + }); + + it('should map seller listing data correctly', async () => { + mockPrisma.listing.findMany.mockResolvedValue([ + { + id: 'listing-1', + status: 'ACTIVE', + transactionType: 'SALE', + priceVND: 3_000_000_000n, + property: { + id: 'prop-1', + title: 'Nhà phố đẹp', + district: 'Quận 3', + city: 'Hồ Chí Minh', + areaM2: 120, + media: [{ url: 'https://cdn.example.com/thumb.jpg' }], + }, + }, + ]); + mockPrisma.listing.count.mockResolvedValue(1); + + const result = await findBySellerIdQuery(mockPrisma as any, 'seller-1', 1, 10); + + expect(result.data).toHaveLength(1); + expect(result.data[0]!.priceVND).toBe('3000000000'); + expect(result.data[0]!.property.thumbnail).toBe('https://cdn.example.com/thumb.jpg'); + }); + }); +}); diff --git a/apps/api/src/modules/listings/infrastructure/__tests__/prisma-duplicate-detector.spec.ts b/apps/api/src/modules/listings/infrastructure/__tests__/prisma-duplicate-detector.spec.ts new file mode 100644 index 0000000..3f1af67 --- /dev/null +++ b/apps/api/src/modules/listings/infrastructure/__tests__/prisma-duplicate-detector.spec.ts @@ -0,0 +1,98 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { PrismaDuplicateDetector } from '../services/prisma-duplicate-detector'; + +describe('PrismaDuplicateDetector', () => { + let detector: PrismaDuplicateDetector; + let mockPrisma: { $queryRaw: ReturnType }; + + beforeEach(() => { + mockPrisma = { + $queryRaw: vi.fn().mockResolvedValue([]), + }; + detector = new PrismaDuplicateDetector(mockPrisma as any); + }); + + it('should return empty array when no nearby properties found', async () => { + mockPrisma.$queryRaw.mockResolvedValue([]); + + const result = await detector.findDuplicates({ + excludePropertyId: 'prop-new', + latitude: 10.7769, + longitude: 106.7009, + title: 'Căn hộ đẹp Quận 1', + propertyType: 'APARTMENT', + }); + + expect(result).toEqual([]); + expect(mockPrisma.$queryRaw).toHaveBeenCalledTimes(1); + }); + + it('should return duplicates when nearby properties with similar titles exist', async () => { + mockPrisma.$queryRaw.mockResolvedValue([ + { + listing_id: 'listing-dup', + property_id: 'prop-dup', + title: 'Căn hộ đẹp Quận 1', + address: '123 Nguyễn Huệ', + district: 'Quận 1', + property_type: 'APARTMENT', + distance_meters: 50, + }, + ]); + + const result = await detector.findDuplicates({ + excludePropertyId: 'prop-new', + latitude: 10.7769, + longitude: 106.7009, + title: 'Căn hộ đẹp Quận 1', + propertyType: 'APARTMENT', + }); + + expect(result).toHaveLength(1); + expect(result[0]!.listingId).toBe('listing-dup'); + expect(result[0]!.propertyId).toBe('prop-dup'); + expect(result[0]!.distanceMeters).toBe(50); + expect(result[0]!.titleSimilarity).toBe(1); // exact match + }); + + it('should filter out low-similarity results', async () => { + mockPrisma.$queryRaw.mockResolvedValue([ + { + listing_id: 'listing-diff', + property_id: 'prop-diff', + title: 'Biệt thự sang trọng Thảo Điền', + address: '456 Quốc Hương', + district: 'Quận 2', + property_type: 'APARTMENT', + distance_meters: 80, + }, + ]); + + const result = await detector.findDuplicates({ + excludePropertyId: 'prop-new', + latitude: 10.7769, + longitude: 106.7009, + title: 'Căn hộ đẹp Quận 1', + propertyType: 'APARTMENT', + }); + + // Titles are very different, so similarity should be below threshold + expect(result).toHaveLength(0); + }); + + it('should use custom radius and similarity threshold', async () => { + mockPrisma.$queryRaw.mockResolvedValue([]); + + await detector.findDuplicates({ + excludePropertyId: 'prop-new', + latitude: 10.7769, + longitude: 106.7009, + title: 'Test listing', + propertyType: 'TOWNHOUSE', + radiusMeters: 200, + minTitleSimilarity: 0.9, + }); + + expect(mockPrisma.$queryRaw).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/api/src/modules/listings/infrastructure/__tests__/prisma-listing.repository.spec.ts b/apps/api/src/modules/listings/infrastructure/__tests__/prisma-listing.repository.spec.ts new file mode 100644 index 0000000..9bfc41f --- /dev/null +++ b/apps/api/src/modules/listings/infrastructure/__tests__/prisma-listing.repository.spec.ts @@ -0,0 +1,141 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { ListingEntity } from '../../domain/entities/listing.entity'; +import { Price } from '../../domain/value-objects/price.vo'; +import { PrismaListingRepository } from '../repositories/prisma-listing.repository'; + +describe('PrismaListingRepository', () => { + let repository: PrismaListingRepository; + let mockPrisma: { + listing: { + findUnique: ReturnType; + create: ReturnType; + update: ReturnType; + findMany: ReturnType; + count: ReturnType; + }; + }; + + beforeEach(() => { + mockPrisma = { + listing: { + findUnique: vi.fn(), + create: vi.fn().mockResolvedValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + findMany: vi.fn().mockResolvedValue([]), + count: vi.fn().mockResolvedValue(0), + }, + }; + repository = new PrismaListingRepository(mockPrisma as any); + }); + + describe('findById', () => { + it('should return null when listing not found', async () => { + mockPrisma.listing.findUnique.mockResolvedValue(null); + + const result = await repository.findById('non-existent'); + + expect(result).toBeNull(); + expect(mockPrisma.listing.findUnique).toHaveBeenCalledWith({ + where: { id: 'non-existent' }, + }); + }); + + it('should return a ListingEntity when listing is found', async () => { + const now = new Date(); + mockPrisma.listing.findUnique.mockResolvedValue({ + id: 'listing-1', + propertyId: 'prop-1', + agentId: 'agent-1', + sellerId: 'seller-1', + transactionType: 'SALE', + status: 'ACTIVE', + priceVND: 5_000_000_000n, + pricePerM2: 62_500_000, + rentPriceMonthly: null, + commissionPct: 2.0, + aiPriceEstimate: null, + aiConfidence: null, + moderationScore: null, + moderationNotes: null, + viewCount: 10, + saveCount: 2, + inquiryCount: 1, + featuredUntil: null, + expiresAt: null, + publishedAt: now, + createdAt: now, + updatedAt: now, + }); + + const result = await repository.findById('listing-1'); + + expect(result).toBeInstanceOf(ListingEntity); + expect(result!.id).toBe('listing-1'); + expect(result!.propertyId).toBe('prop-1'); + expect(result!.status).toBe('ACTIVE'); + expect(result!.viewCount).toBe(10); + }); + }); + + describe('save', () => { + it('should call prisma.listing.create with correct data', async () => { + const price = Price.create(5_000_000_000n).unwrap(); + const listing = ListingEntity.createNew( + 'listing-new', + 'prop-new', + 'seller-1', + 'SALE', + price, + 100, + 'agent-1', + ); + + await repository.save(listing); + + expect(mockPrisma.listing.create).toHaveBeenCalledTimes(1); + const createCall = mockPrisma.listing.create.mock.calls[0]![0]; + expect(createCall.data.id).toBe('listing-new'); + expect(createCall.data.propertyId).toBe('prop-new'); + expect(createCall.data.sellerId).toBe('seller-1'); + expect(createCall.data.status).toBe('DRAFT'); + }); + }); + + describe('update', () => { + it('should call prisma.listing.update with correct data', async () => { + const price = Price.create(5_000_000_000n).unwrap(); + const listing = ListingEntity.createNew( + 'listing-upd', + 'prop-upd', + 'seller-1', + 'SALE', + price, + 80, + ); + + await repository.update(listing); + + expect(mockPrisma.listing.update).toHaveBeenCalledTimes(1); + const updateCall = mockPrisma.listing.update.mock.calls[0]![0]; + expect(updateCall.where.id).toBe('listing-upd'); + expect(updateCall.data.status).toBe('DRAFT'); + }); + }); + + describe('findByStatus', () => { + it('should delegate to search with status filter', async () => { + mockPrisma.listing.findMany.mockResolvedValue([]); + mockPrisma.listing.count.mockResolvedValue(0); + + const result = await repository.findByStatus('PENDING_REVIEW', 1, 20); + + expect(result).toEqual({ + data: [], + total: 0, + page: 1, + limit: 20, + totalPages: 0, + }); + }); + }); +}); diff --git a/apps/api/src/modules/listings/infrastructure/__tests__/prisma-price-validator.spec.ts b/apps/api/src/modules/listings/infrastructure/__tests__/prisma-price-validator.spec.ts new file mode 100644 index 0000000..b4a01fc --- /dev/null +++ b/apps/api/src/modules/listings/infrastructure/__tests__/prisma-price-validator.spec.ts @@ -0,0 +1,117 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { PrismaPriceValidator } from '../services/prisma-price-validator'; + +describe('PrismaPriceValidator', () => { + let validator: PrismaPriceValidator; + let mockPrisma: { $queryRaw: ReturnType }; + let mockLogger: { log: ReturnType; warn: ReturnType; error: ReturnType }; + + beforeEach(() => { + mockPrisma = { + $queryRaw: vi.fn().mockResolvedValue([]), + }; + mockLogger = { + log: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + validator = new PrismaPriceValidator(mockPrisma as any, mockLogger as any); + }); + + it('should return valid for normal price within default range', async () => { + // No market data — falls back to defaults + mockPrisma.$queryRaw.mockResolvedValue([]); + + const result = await validator.validate({ + priceVND: 5_000_000_000n, + areaM2: 80, + propertyType: 'APARTMENT', + district: 'Quận 1', + }); + + // 5B / 80 = 62.5M per m2; APARTMENT default range: 15M-200M + expect(result.isValid).toBe(true); + expect(result.isSuspicious).toBe(false); + expect(result.pricePerM2).toBe(62_500_000); + }); + + it('should return invalid for zero area', async () => { + mockPrisma.$queryRaw.mockResolvedValue([]); + + const result = await validator.validate({ + priceVND: 5_000_000_000n, + areaM2: 0, + propertyType: 'APARTMENT', + district: 'Quận 1', + }); + + expect(result.isValid).toBe(false); + expect(result.isSuspicious).toBe(true); + expect(result.reason).toContain('không hợp lệ'); + }); + + it('should flag suspiciously low price', async () => { + mockPrisma.$queryRaw.mockResolvedValue([]); + + // 100M / 100 = 1M per m2 — way below APARTMENT min of 15M * 0.5 = 7.5M + const result = await validator.validate({ + priceVND: 100_000_000n, + areaM2: 100, + propertyType: 'APARTMENT', + district: 'Quận 7', + }); + + expect(result.isValid).toBe(true); + expect(result.isSuspicious).toBe(true); + expect(result.reason).toContain('thấp hơn'); + }); + + it('should flag suspiciously high price', async () => { + mockPrisma.$queryRaw.mockResolvedValue([]); + + // 100B / 100 = 1B per m2 — above APARTMENT max of 200M * 1.5 = 300M + const result = await validator.validate({ + priceVND: 100_000_000_000n, + areaM2: 100, + propertyType: 'APARTMENT', + district: 'Quận 1', + }); + + expect(result.isValid).toBe(true); + expect(result.isSuspicious).toBe(true); + expect(result.reason).toContain('cao hơn'); + }); + + it('should use market data when available', async () => { + mockPrisma.$queryRaw.mockResolvedValue([ + { min_price: 50_000_000, max_price: 100_000_000 }, + ]); + + const result = await validator.validate({ + priceVND: 6_000_000_000n, + areaM2: 80, + propertyType: 'APARTMENT', + district: 'Quận 1', + }); + + // 6B / 80 = 75M per m2; market range: 50M-100M — within range + expect(result.isValid).toBe(true); + expect(result.isSuspicious).toBe(false); + expect(result.minPricePerM2).toBe(50_000_000); + expect(result.maxPricePerM2).toBe(100_000_000); + }); + + it('should fall back to defaults when market query fails', async () => { + mockPrisma.$queryRaw.mockRejectedValue(new Error('DB error')); + + const result = await validator.validate({ + priceVND: 5_000_000_000n, + areaM2: 80, + propertyType: 'APARTMENT', + district: 'Quận 1', + }); + + expect(result.isValid).toBe(true); + expect(mockLogger.warn).toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/modules/listings/infrastructure/__tests__/prisma-property.repository.spec.ts b/apps/api/src/modules/listings/infrastructure/__tests__/prisma-property.repository.spec.ts new file mode 100644 index 0000000..1e704bf --- /dev/null +++ b/apps/api/src/modules/listings/infrastructure/__tests__/prisma-property.repository.spec.ts @@ -0,0 +1,169 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { PropertyMediaEntity } from '../../domain/entities/property-media.entity'; +import { PropertyEntity } from '../../domain/entities/property.entity'; +import { PrismaPropertyRepository } from '../repositories/prisma-property.repository'; + +describe('PrismaPropertyRepository', () => { + let repository: PrismaPropertyRepository; + let mockPrisma: { + property: { + findUnique: ReturnType; + }; + propertyMedia: { + create: ReturnType; + findMany: ReturnType; + delete: ReturnType; + count: ReturnType; + }; + $executeRaw: ReturnType; + }; + + beforeEach(() => { + mockPrisma = { + property: { + findUnique: vi.fn(), + }, + propertyMedia: { + create: vi.fn().mockResolvedValue(undefined), + findMany: vi.fn().mockResolvedValue([]), + delete: vi.fn().mockResolvedValue(undefined), + count: vi.fn().mockResolvedValue(0), + }, + $executeRaw: vi.fn().mockResolvedValue(1), + }; + repository = new PrismaPropertyRepository(mockPrisma as any); + }); + + describe('findById', () => { + it('should return null when property not found', async () => { + mockPrisma.property.findUnique.mockResolvedValue(null); + + const result = await repository.findById('non-existent'); + + expect(result).toBeNull(); + expect(mockPrisma.property.findUnique).toHaveBeenCalledWith({ + where: { id: 'non-existent' }, + }); + }); + + it('should return a PropertyEntity when property is found', async () => { + const now = new Date(); + mockPrisma.property.findUnique.mockResolvedValue({ + id: 'prop-1', + propertyType: 'APARTMENT', + title: 'Căn hộ đẹp', + description: 'Mô tả chi tiết', + address: '123 Nguyễn Huệ', + ward: 'Bến Nghé', + district: 'Quận 1', + city: 'Hồ Chí Minh', + location: null, // PostGIS geometry placeholder + areaM2: 80, + usableAreaM2: 70, + bedrooms: 2, + bathrooms: 2, + floors: null, + floor: 10, + totalFloors: 25, + direction: 'EAST', + yearBuilt: 2022, + legalStatus: 'Sổ hồng', + amenities: null, + nearbyPOIs: null, + metroDistanceM: 300, + projectName: 'Vinhomes', + createdAt: now, + updatedAt: now, + }); + + const result = await repository.findById('prop-1'); + + expect(result).toBeInstanceOf(PropertyEntity); + expect(result!.id).toBe('prop-1'); + expect(result!.title).toBe('Căn hộ đẹp'); + expect(result!.propertyType).toBe('APARTMENT'); + }); + }); + + describe('addMedia', () => { + it('should call prisma.propertyMedia.create with correct data', async () => { + const media = PropertyMediaEntity.createNew( + 'media-1', + 'prop-1', + 'https://cdn.example.com/photo.jpg', + 'image', + 0, + 'Mặt tiền', + ); + + await repository.addMedia(media); + + expect(mockPrisma.propertyMedia.create).toHaveBeenCalledTimes(1); + const createCall = mockPrisma.propertyMedia.create.mock.calls[0]![0]; + expect(createCall.data.id).toBe('media-1'); + expect(createCall.data.propertyId).toBe('prop-1'); + expect(createCall.data.url).toBe('https://cdn.example.com/photo.jpg'); + expect(createCall.data.type).toBe('image'); + expect(createCall.data.order).toBe(0); + expect(createCall.data.caption).toBe('Mặt tiền'); + }); + }); + + describe('findMediaByPropertyId', () => { + it('should return mapped PropertyMediaEntity array', async () => { + const now = new Date(); + mockPrisma.propertyMedia.findMany.mockResolvedValue([ + { + id: 'media-1', + propertyId: 'prop-1', + url: 'https://cdn.example.com/1.jpg', + type: 'image', + order: 0, + caption: null, + aiTags: null, + createdAt: now, + }, + { + id: 'media-2', + propertyId: 'prop-1', + url: 'https://cdn.example.com/2.jpg', + type: 'image', + order: 1, + caption: 'Phòng ngủ', + aiTags: null, + createdAt: now, + }, + ]); + + const result = await repository.findMediaByPropertyId('prop-1'); + + expect(result).toHaveLength(2); + expect(result[0]).toBeInstanceOf(PropertyMediaEntity); + expect(result[0]!.id).toBe('media-1'); + expect(result[1]!.caption).toBe('Phòng ngủ'); + }); + }); + + describe('deleteMedia', () => { + it('should call prisma.propertyMedia.delete', async () => { + await repository.deleteMedia('media-1'); + + expect(mockPrisma.propertyMedia.delete).toHaveBeenCalledWith({ + where: { id: 'media-1' }, + }); + }); + }); + + describe('countMediaByPropertyId', () => { + it('should return count from prisma', async () => { + mockPrisma.propertyMedia.count.mockResolvedValue(5); + + const result = await repository.countMediaByPropertyId('prop-1'); + + expect(result).toBe(5); + expect(mockPrisma.propertyMedia.count).toHaveBeenCalledWith({ + where: { propertyId: 'prop-1' }, + }); + }); + }); +}); diff --git a/apps/api/src/modules/listings/presentation/__tests__/create-listing.dto.spec.ts b/apps/api/src/modules/listings/presentation/__tests__/create-listing.dto.spec.ts new file mode 100644 index 0000000..6ccd274 --- /dev/null +++ b/apps/api/src/modules/listings/presentation/__tests__/create-listing.dto.spec.ts @@ -0,0 +1,140 @@ +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; +import { describe, it, expect } from 'vitest'; +import { CreateListingDto } from '../dto/create-listing.dto'; + +describe('CreateListingDto', () => { + it('should pass validation with all required fields', async () => { + const dto = plainToInstance(CreateListingDto, { + transactionType: 'SALE', + priceVND: '5500000000', + propertyType: 'APARTMENT', + title: 'Căn hộ 3PN view sông Sài Gòn', + description: 'Căn hộ cao cấp 3 phòng ngủ, nội thất đầy đủ', + address: '208 Nguyễn Hữu Cảnh', + ward: 'Phường 22', + district: 'Bình Thạnh', + city: 'Hồ Chí Minh', + latitude: 10.7942, + longitude: 106.7219, + areaM2: 85.5, + }); + + const errors = await validate(dto); + expect(errors).toHaveLength(0); + }); + + it('should fail validation when title is too short', async () => { + const dto = plainToInstance(CreateListingDto, { + transactionType: 'SALE', + priceVND: '5000000000', + propertyType: 'APARTMENT', + title: 'AB', + description: 'Căn hộ cao cấp 3 phòng ngủ, nội thất đầy đủ', + address: '123 ABC', + ward: 'Ward', + district: 'District', + city: 'City', + latitude: 10.77, + longitude: 106.70, + areaM2: 80, + }); + + const errors = await validate(dto); + expect(errors.length).toBeGreaterThan(0); + const titleError = errors.find((e) => e.property === 'title'); + expect(titleError).toBeDefined(); + }); + + it('should fail validation with invalid transactionType', async () => { + const dto = plainToInstance(CreateListingDto, { + transactionType: 'INVALID', + priceVND: '5000000000', + propertyType: 'APARTMENT', + title: 'Căn hộ đẹp Quận 1', + description: 'Mô tả đầy đủ chi tiết', + address: '123 ABC', + ward: 'Ward', + district: 'District', + city: 'City', + latitude: 10.77, + longitude: 106.70, + areaM2: 80, + }); + + const errors = await validate(dto); + expect(errors.length).toBeGreaterThan(0); + const typeError = errors.find((e) => e.property === 'transactionType'); + expect(typeError).toBeDefined(); + }); + + it('should fail validation when latitude is out of range', async () => { + const dto = plainToInstance(CreateListingDto, { + transactionType: 'SALE', + priceVND: '5000000000', + propertyType: 'APARTMENT', + title: 'Căn hộ đẹp Quận 1', + description: 'Mô tả đầy đủ chi tiết', + address: '123 ABC', + ward: 'Ward', + district: 'District', + city: 'City', + latitude: 999, + longitude: 106.70, + areaM2: 80, + }); + + const errors = await validate(dto); + expect(errors.length).toBeGreaterThan(0); + const latError = errors.find((e) => e.property === 'latitude'); + expect(latError).toBeDefined(); + }); + + it('should pass validation with optional fields', async () => { + const dto = plainToInstance(CreateListingDto, { + transactionType: 'SALE', + priceVND: '5500000000', + propertyType: 'TOWNHOUSE', + title: 'Nhà phố đẹp Quận 3', + description: 'Nhà phố 3 tầng mặt tiền rộng', + address: '456 Lê Lợi', + ward: 'Phường 1', + district: 'Quận 3', + city: 'Hồ Chí Minh', + latitude: 10.78, + longitude: 106.69, + areaM2: 120, + bedrooms: 3, + bathrooms: 2, + floors: 3, + direction: 'EAST', + yearBuilt: 2020, + legalStatus: 'Sổ hồng', + }); + + const errors = await validate(dto); + expect(errors).toHaveLength(0); + }); + + it('should fail validation when areaM2 is less than 1', async () => { + const dto = plainToInstance(CreateListingDto, { + transactionType: 'SALE', + priceVND: '5000000000', + propertyType: 'APARTMENT', + title: 'Căn hộ đẹp Quận 1', + description: 'Mô tả đầy đủ chi tiết', + address: '123 ABC', + ward: 'Ward', + district: 'District', + city: 'City', + latitude: 10.77, + longitude: 106.70, + areaM2: 0, + }); + + const errors = await validate(dto); + expect(errors.length).toBeGreaterThan(0); + const areaError = errors.find((e) => e.property === 'areaM2'); + expect(areaError).toBeDefined(); + }); +}); diff --git a/apps/api/src/modules/listings/presentation/__tests__/listings.controller.spec.ts b/apps/api/src/modules/listings/presentation/__tests__/listings.controller.spec.ts new file mode 100644 index 0000000..8612dc6 --- /dev/null +++ b/apps/api/src/modules/listings/presentation/__tests__/listings.controller.spec.ts @@ -0,0 +1,132 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { ListingsController } from '../controllers/listings.controller'; + +describe('ListingsController', () => { + let controller: ListingsController; + let mockCommandBus: { execute: ReturnType }; + let mockQueryBus: { execute: ReturnType }; + + beforeEach(() => { + mockCommandBus = { execute: vi.fn() }; + mockQueryBus = { execute: vi.fn() }; + controller = new ListingsController(mockCommandBus as any, mockQueryBus as any); + }); + + describe('createListing', () => { + it('should execute CreateListingCommand via command bus', async () => { + const mockResult = { + listingId: 'listing-1', + propertyId: 'prop-1', + status: 'DRAFT', + duplicateWarnings: [], + }; + mockCommandBus.execute.mockResolvedValue(mockResult); + + const dto = { + transactionType: 'SALE' as const, + priceVND: 5_000_000_000n, + propertyType: 'APARTMENT' as const, + title: 'Căn hộ đẹp Quận 1', + description: 'Mô tả chi tiết căn hộ', + address: '123 Nguyễn Huệ', + ward: 'Bến Nghé', + district: 'Quận 1', + city: 'Hồ Chí Minh', + latitude: 10.7769, + longitude: 106.7009, + areaM2: 80, + }; + const user = { sub: 'seller-1', email: 'test@example.com', role: 'SELLER' }; + + const result = await controller.createListing(dto as any, user as any); + + expect(result).toEqual(mockResult); + expect(mockCommandBus.execute).toHaveBeenCalledTimes(1); + }); + }); + + describe('getListing', () => { + it('should execute GetListingQuery via query bus', async () => { + const mockDetail = { + id: 'listing-1', + status: 'ACTIVE', + transactionType: 'SALE', + priceVND: '5000000000', + }; + mockQueryBus.execute.mockResolvedValue(mockDetail); + + const result = await controller.getListing('listing-1'); + + expect(result).toEqual(mockDetail); + expect(mockQueryBus.execute).toHaveBeenCalledTimes(1); + }); + }); + + describe('searchListings', () => { + it('should execute SearchListingsQuery via query bus', async () => { + const mockResults = { + data: [], + total: 0, + page: 1, + limit: 20, + totalPages: 0, + }; + mockQueryBus.execute.mockResolvedValue(mockResults); + + const dto = { status: 'ACTIVE' as const, page: 1, limit: 20 }; + const result = await controller.searchListings(dto as any); + + expect(result).toEqual(mockResults); + expect(mockQueryBus.execute).toHaveBeenCalledTimes(1); + }); + }); + + describe('updateStatus', () => { + it('should execute UpdateListingStatusCommand via command bus', async () => { + mockCommandBus.execute.mockResolvedValue({ status: 'ACTIVE' }); + + const dto = { status: 'ACTIVE' as const }; + const user = { sub: 'admin-1', email: 'admin@example.com', role: 'ADMIN' }; + + const result = await controller.updateStatus('listing-1', dto as any, user as any); + + expect(result).toEqual({ status: 'ACTIVE' }); + expect(mockCommandBus.execute).toHaveBeenCalledTimes(1); + }); + }); + + describe('getPendingModeration', () => { + it('should execute GetPendingModerationQuery with defaults', async () => { + const mockResults = { data: [], total: 0, page: 1, limit: 20, totalPages: 0 }; + mockQueryBus.execute.mockResolvedValue(mockResults); + + const result = await controller.getPendingModeration(); + + expect(result).toEqual(mockResults); + expect(mockQueryBus.execute).toHaveBeenCalledTimes(1); + }); + + it('should execute GetPendingModerationQuery with custom page/limit', async () => { + const mockResults = { data: [], total: 0, page: 2, limit: 10, totalPages: 0 }; + mockQueryBus.execute.mockResolvedValue(mockResults); + + const result = await controller.getPendingModeration(2, 10); + + expect(result).toEqual(mockResults); + }); + }); + + describe('moderateListing', () => { + it('should execute ModerateListingCommand via command bus', async () => { + mockCommandBus.execute.mockResolvedValue({ status: 'ACTIVE' }); + + const dto = { action: 'approve' as const, moderationScore: 90, notes: 'Hợp lệ' }; + const user = { sub: 'admin-1', email: 'admin@example.com', role: 'ADMIN' }; + + const result = await controller.moderateListing('listing-1', dto as any, user as any); + + expect(result).toEqual({ status: 'ACTIVE' }); + expect(mockCommandBus.execute).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/apps/api/src/modules/listings/presentation/__tests__/moderate-listing.dto.spec.ts b/apps/api/src/modules/listings/presentation/__tests__/moderate-listing.dto.spec.ts new file mode 100644 index 0000000..a16c47d --- /dev/null +++ b/apps/api/src/modules/listings/presentation/__tests__/moderate-listing.dto.spec.ts @@ -0,0 +1,76 @@ +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; +import { describe, it, expect } from 'vitest'; +import { ModerateListingDto } from '../dto/moderate-listing.dto'; + +describe('ModerateListingDto', () => { + it('should pass validation with approve action', async () => { + const dto = plainToInstance(ModerateListingDto, { + action: 'approve', + }); + + const errors = await validate(dto); + expect(errors).toHaveLength(0); + expect(dto.action).toBe('approve'); + }); + + it('should pass validation with reject action and notes', async () => { + const dto = plainToInstance(ModerateListingDto, { + action: 'reject', + notes: 'Ảnh không rõ ràng', + moderationScore: 30, + }); + + const errors = await validate(dto); + expect(errors).toHaveLength(0); + expect(dto.action).toBe('reject'); + expect(dto.notes).toBe('Ảnh không rõ ràng'); + expect(dto.moderationScore).toBe(30); + }); + + it('should fail validation with invalid action', async () => { + const dto = plainToInstance(ModerateListingDto, { + action: 'suspend', + }); + + const errors = await validate(dto); + expect(errors.length).toBeGreaterThan(0); + const actionError = errors.find((e) => e.property === 'action'); + expect(actionError).toBeDefined(); + }); + + it('should fail validation when moderationScore exceeds 100', async () => { + const dto = plainToInstance(ModerateListingDto, { + action: 'approve', + moderationScore: 150, + }); + + const errors = await validate(dto); + expect(errors.length).toBeGreaterThan(0); + const scoreError = errors.find((e) => e.property === 'moderationScore'); + expect(scoreError).toBeDefined(); + }); + + it('should fail validation when moderationScore is negative', async () => { + const dto = plainToInstance(ModerateListingDto, { + action: 'approve', + moderationScore: -10, + }); + + const errors = await validate(dto); + expect(errors.length).toBeGreaterThan(0); + const scoreError = errors.find((e) => e.property === 'moderationScore'); + expect(scoreError).toBeDefined(); + }); + + it('should pass validation with optional fields omitted', async () => { + const dto = plainToInstance(ModerateListingDto, { + action: 'reject', + }); + + const errors = await validate(dto); + expect(errors).toHaveLength(0); + expect(dto.moderationScore).toBeUndefined(); + expect(dto.notes).toBeUndefined(); + }); +}); diff --git a/apps/api/src/modules/listings/presentation/__tests__/search-listings.dto.spec.ts b/apps/api/src/modules/listings/presentation/__tests__/search-listings.dto.spec.ts new file mode 100644 index 0000000..24da9db --- /dev/null +++ b/apps/api/src/modules/listings/presentation/__tests__/search-listings.dto.spec.ts @@ -0,0 +1,77 @@ +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; +import { describe, it, expect } from 'vitest'; +import { SearchListingsDto } from '../dto/search-listings.dto'; + +describe('SearchListingsDto', () => { + it('should pass validation with no filters (all optional)', async () => { + const dto = plainToInstance(SearchListingsDto, {}); + + const errors = await validate(dto); + expect(errors).toHaveLength(0); + }); + + it('should pass validation with valid status and filters', async () => { + const dto = plainToInstance(SearchListingsDto, { + status: 'ACTIVE', + transactionType: 'SALE', + propertyType: 'APARTMENT', + city: 'Hồ Chí Minh', + district: 'Quận 1', + page: 1, + limit: 20, + }); + + const errors = await validate(dto); + expect(errors).toHaveLength(0); + expect(dto.status).toBe('ACTIVE'); + expect(dto.transactionType).toBe('SALE'); + }); + + it('should fail validation with invalid status enum', async () => { + const dto = plainToInstance(SearchListingsDto, { + status: 'INVALID_STATUS', + }); + + const errors = await validate(dto); + expect(errors.length).toBeGreaterThan(0); + const statusError = errors.find((e) => e.property === 'status'); + expect(statusError).toBeDefined(); + }); + + it('should fail validation when limit exceeds 100', async () => { + const dto = plainToInstance(SearchListingsDto, { + limit: 200, + }); + + const errors = await validate(dto); + expect(errors.length).toBeGreaterThan(0); + const limitError = errors.find((e) => e.property === 'limit'); + expect(limitError).toBeDefined(); + }); + + it('should fail validation when page is less than 1', async () => { + const dto = plainToInstance(SearchListingsDto, { + page: 0, + }); + + const errors = await validate(dto); + expect(errors.length).toBeGreaterThan(0); + const pageError = errors.find((e) => e.property === 'page'); + expect(pageError).toBeDefined(); + }); + + it('should pass validation with area and bedroom filters', async () => { + const dto = plainToInstance(SearchListingsDto, { + minArea: 50, + maxArea: 200, + bedrooms: 2, + }); + + const errors = await validate(dto); + expect(errors).toHaveLength(0); + expect(dto.minArea).toBe(50); + expect(dto.maxArea).toBe(200); + expect(dto.bedrooms).toBe(2); + }); +}); diff --git a/apps/api/src/modules/listings/presentation/__tests__/update-listing-status.dto.spec.ts b/apps/api/src/modules/listings/presentation/__tests__/update-listing-status.dto.spec.ts new file mode 100644 index 0000000..73d9c41 --- /dev/null +++ b/apps/api/src/modules/listings/presentation/__tests__/update-listing-status.dto.spec.ts @@ -0,0 +1,58 @@ +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; +import { describe, it, expect } from 'vitest'; +import { UpdateListingStatusDto } from '../dto/update-listing-status.dto'; + +describe('UpdateListingStatusDto', () => { + it('should pass validation with valid status', async () => { + const dto = plainToInstance(UpdateListingStatusDto, { + status: 'ACTIVE', + }); + + const errors = await validate(dto); + expect(errors).toHaveLength(0); + expect(dto.status).toBe('ACTIVE'); + }); + + it('should pass validation with status and moderation notes', async () => { + const dto = plainToInstance(UpdateListingStatusDto, { + status: 'REJECTED', + moderationNotes: 'Đã xác minh thông tin pháp lý', + }); + + const errors = await validate(dto); + expect(errors).toHaveLength(0); + expect(dto.status).toBe('REJECTED'); + expect(dto.moderationNotes).toBe('Đã xác minh thông tin pháp lý'); + }); + + it('should fail validation with invalid status', async () => { + const dto = plainToInstance(UpdateListingStatusDto, { + status: 'INVALID_STATUS', + }); + + const errors = await validate(dto); + expect(errors.length).toBeGreaterThan(0); + const statusError = errors.find((e) => e.property === 'status'); + expect(statusError).toBeDefined(); + }); + + it('should fail validation when status is missing', async () => { + const dto = plainToInstance(UpdateListingStatusDto, {}); + + const errors = await validate(dto); + expect(errors.length).toBeGreaterThan(0); + const statusError = errors.find((e) => e.property === 'status'); + expect(statusError).toBeDefined(); + }); + + it('should accept all valid ListingStatus enum values', async () => { + const validStatuses = ['DRAFT', 'PENDING_REVIEW', 'ACTIVE', 'RESERVED', 'SOLD', 'RENTED', 'EXPIRED', 'REJECTED']; + + for (const status of validStatuses) { + const dto = plainToInstance(UpdateListingStatusDto, { status }); + const errors = await validate(dto); + expect(errors).toHaveLength(0); + } + }); +}); diff --git a/apps/api/src/modules/search/domain/__tests__/geo-filter.vo.spec.ts b/apps/api/src/modules/search/domain/__tests__/geo-filter.vo.spec.ts new file mode 100644 index 0000000..62a1c41 --- /dev/null +++ b/apps/api/src/modules/search/domain/__tests__/geo-filter.vo.spec.ts @@ -0,0 +1,68 @@ +import { describe, it, expect } from 'vitest'; +import { GeoFilter } from '../value-objects/geo-filter.vo'; + +describe('GeoFilter', () => { + it('creates filter with all properties', () => { + const filter = GeoFilter.create({ + lat: 10.7769, + lng: 106.7009, + radiusKm: 5, + propertyType: 'APARTMENT', + transactionType: 'RENT', + priceMin: 5_000_000, + priceMax: 20_000_000, + sortBy: 'price_asc', + page: 3, + perPage: 25, + }); + + expect(filter.lat).toBe(10.7769); + expect(filter.lng).toBe(106.7009); + expect(filter.radiusKm).toBe(5); + expect(filter.propertyType).toBe('APARTMENT'); + expect(filter.transactionType).toBe('RENT'); + expect(filter.priceMin).toBe(5_000_000); + expect(filter.priceMax).toBe(20_000_000); + expect(filter.sortBy).toBe('price_asc'); + expect(filter.page).toBe(3); + expect(filter.perPage).toBe(25); + }); + + it('applies default sortBy as distance', () => { + const filter = GeoFilter.create({ lat: 10.7, lng: 106.7, radiusKm: 5 }); + expect(filter.sortBy).toBe('distance'); + }); + + it('applies default page as 1', () => { + const filter = GeoFilter.create({ lat: 10.7, lng: 106.7, radiusKm: 5 }); + expect(filter.page).toBe(1); + }); + + it('applies default perPage as 20', () => { + const filter = GeoFilter.create({ lat: 10.7, lng: 106.7, radiusKm: 5 }); + expect(filter.perPage).toBe(20); + }); + + it('caps radiusKm at 100', () => { + const filter = GeoFilter.create({ lat: 10.7, lng: 106.7, radiusKm: 200 }); + expect(filter.radiusKm).toBe(100); + }); + + it('does not cap radiusKm when under 100', () => { + const filter = GeoFilter.create({ lat: 10.7, lng: 106.7, radiusKm: 50 }); + expect(filter.radiusKm).toBe(50); + }); + + it('caps perPage at 100', () => { + const filter = GeoFilter.create({ lat: 10.7, lng: 106.7, radiusKm: 5, perPage: 500 }); + expect(filter.perPage).toBe(100); + }); + + it('returns undefined for unset optional properties', () => { + const filter = GeoFilter.create({ lat: 10.7, lng: 106.7, radiusKm: 5 }); + expect(filter.propertyType).toBeUndefined(); + expect(filter.transactionType).toBeUndefined(); + expect(filter.priceMin).toBeUndefined(); + expect(filter.priceMax).toBeUndefined(); + }); +}); diff --git a/apps/api/src/modules/search/domain/__tests__/search-filter.vo.spec.ts b/apps/api/src/modules/search/domain/__tests__/search-filter.vo.spec.ts new file mode 100644 index 0000000..81331e6 --- /dev/null +++ b/apps/api/src/modules/search/domain/__tests__/search-filter.vo.spec.ts @@ -0,0 +1,75 @@ +import { describe, it, expect } from 'vitest'; +import { SearchFilter } from '../value-objects/search-filter.vo'; + +describe('SearchFilter', () => { + it('creates filter with all properties', () => { + const filter = SearchFilter.create({ + query: 'căn hộ quận 7', + propertyType: 'APARTMENT', + transactionType: 'SALE', + priceMin: 2_000_000_000, + priceMax: 8_000_000_000, + areaMin: 60, + areaMax: 150, + bedrooms: 2, + district: 'Quận 7', + city: 'Hồ Chí Minh', + sortBy: 'price_desc', + page: 4, + perPage: 30, + }); + + expect(filter.query).toBe('căn hộ quận 7'); + expect(filter.propertyType).toBe('APARTMENT'); + expect(filter.transactionType).toBe('SALE'); + expect(filter.priceMin).toBe(2_000_000_000); + expect(filter.priceMax).toBe(8_000_000_000); + expect(filter.areaMin).toBe(60); + expect(filter.areaMax).toBe(150); + expect(filter.bedrooms).toBe(2); + expect(filter.district).toBe('Quận 7'); + expect(filter.city).toBe('Hồ Chí Minh'); + expect(filter.sortBy).toBe('price_desc'); + expect(filter.page).toBe(4); + expect(filter.perPage).toBe(30); + }); + + it('applies default sortBy as relevance', () => { + const filter = SearchFilter.create({}); + expect(filter.sortBy).toBe('relevance'); + }); + + it('applies default page as 1', () => { + const filter = SearchFilter.create({}); + expect(filter.page).toBe(1); + }); + + it('applies default perPage as 20', () => { + const filter = SearchFilter.create({}); + expect(filter.perPage).toBe(20); + }); + + it('caps perPage at 100', () => { + const filter = SearchFilter.create({ perPage: 250 }); + expect(filter.perPage).toBe(100); + }); + + it('returns undefined for unset optional properties', () => { + const filter = SearchFilter.create({}); + expect(filter.query).toBeUndefined(); + expect(filter.propertyType).toBeUndefined(); + expect(filter.transactionType).toBeUndefined(); + expect(filter.priceMin).toBeUndefined(); + expect(filter.priceMax).toBeUndefined(); + expect(filter.areaMin).toBeUndefined(); + expect(filter.areaMax).toBeUndefined(); + expect(filter.bedrooms).toBeUndefined(); + expect(filter.district).toBeUndefined(); + expect(filter.city).toBeUndefined(); + }); + + it('handles empty query string', () => { + const filter = SearchFilter.create({ query: '' }); + expect(filter.query).toBe(''); + }); +}); diff --git a/apps/api/src/modules/search/infrastructure/__tests__/listing-status-changed.handler.spec.ts b/apps/api/src/modules/search/infrastructure/__tests__/listing-status-changed.handler.spec.ts new file mode 100644 index 0000000..cd43ad0 --- /dev/null +++ b/apps/api/src/modules/search/infrastructure/__tests__/listing-status-changed.handler.spec.ts @@ -0,0 +1,131 @@ +import { CachePrefix, CacheService } from '@modules/shared/infrastructure/cache.service'; +import { ListingStatusChangedHandler } from '../event-handlers/listing-status-changed.handler'; + +describe('ListingStatusChangedHandler', () => { + let handler: ListingStatusChangedHandler; + let mockIndexer: { indexListing: ReturnType; removeListing: ReturnType }; + let mockCache: { + invalidate: ReturnType; + invalidateByPrefix: ReturnType; + }; + let mockLogger: { log: ReturnType; warn: ReturnType; error: ReturnType }; + + beforeEach(() => { + mockIndexer = { + indexListing: vi.fn().mockResolvedValue(undefined), + removeListing: vi.fn().mockResolvedValue(undefined), + }; + mockCache = { + invalidate: vi.fn().mockResolvedValue(undefined), + invalidateByPrefix: vi.fn().mockResolvedValue(undefined), + }; + mockLogger = { + log: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + handler = new ListingStatusChangedHandler(mockIndexer as any, mockCache as any, mockLogger as any); + }); + + it('removes listing from index when status changed to REJECTED', async () => { + await handler.handle({ + aggregateId: 'listing-1', + propertyId: 'prop-1', + previousStatus: 'ACTIVE', + newStatus: 'REJECTED', + eventName: 'listing.status_changed', + occurredAt: new Date(), + } as any); + + expect(mockIndexer.removeListing).toHaveBeenCalledWith('listing-1'); + }); + + it('removes listing from index when status changed to EXPIRED', async () => { + await handler.handle({ + aggregateId: 'listing-2', + propertyId: 'prop-2', + previousStatus: 'ACTIVE', + newStatus: 'EXPIRED', + eventName: 'listing.status_changed', + occurredAt: new Date(), + } as any); + + expect(mockIndexer.removeListing).toHaveBeenCalledWith('listing-2'); + }); + + it('removes listing from index when status changed to SOLD', async () => { + await handler.handle({ + aggregateId: 'listing-3', + propertyId: 'prop-3', + previousStatus: 'ACTIVE', + newStatus: 'SOLD', + eventName: 'listing.status_changed', + occurredAt: new Date(), + } as any); + + expect(mockIndexer.removeListing).toHaveBeenCalledWith('listing-3'); + }); + + it('removes listing from index when status changed to RENTED', async () => { + await handler.handle({ + aggregateId: 'listing-4', + propertyId: 'prop-4', + previousStatus: 'ACTIVE', + newStatus: 'RENTED', + eventName: 'listing.status_changed', + occurredAt: new Date(), + } as any); + + expect(mockIndexer.removeListing).toHaveBeenCalledWith('listing-4'); + }); + + it('does NOT remove listing from index when status changed to ACTIVE', async () => { + await handler.handle({ + aggregateId: 'listing-5', + propertyId: 'prop-5', + previousStatus: 'PENDING_REVIEW', + newStatus: 'ACTIVE', + eventName: 'listing.status_changed', + occurredAt: new Date(), + } as any); + + expect(mockIndexer.removeListing).not.toHaveBeenCalled(); + }); + + it('invalidates listing cache, search cache, and geo search cache on any status change', async () => { + await handler.handle({ + aggregateId: 'listing-6', + propertyId: 'prop-6', + previousStatus: 'DRAFT', + newStatus: 'ACTIVE', + eventName: 'listing.status_changed', + occurredAt: new Date(), + } as any); + + expect(mockCache.invalidate).toHaveBeenCalledWith( + CacheService.buildKey(CachePrefix.LISTING, 'listing-6'), + ); + expect(mockCache.invalidateByPrefix).toHaveBeenCalledWith(CachePrefix.SEARCH); + expect(mockCache.invalidateByPrefix).toHaveBeenCalledWith(CachePrefix.GEO_SEARCH); + }); + + it('logs the status transition', async () => { + await handler.handle({ + aggregateId: 'listing-7', + propertyId: 'prop-7', + previousStatus: 'ACTIVE', + newStatus: 'SOLD', + eventName: 'listing.status_changed', + occurredAt: new Date(), + } as any); + + expect(mockLogger.log).toHaveBeenCalledWith( + expect.stringContaining('ACTIVE'), + expect.any(String), + ); + expect(mockLogger.log).toHaveBeenCalledWith( + expect.stringContaining('SOLD'), + expect.any(String), + ); + }); +});