test: increase test coverage for listings, auth, and search modules
Add 33 new test files to reach coverage targets: - Listings: 13 → 28 test files (50%+) - Auth: 21 → 36 test files (50%+) - Search: 10 → 13 test files (59%+) New tests cover domain entities, value objects, services, guards, decorators, DTOs, repositories, controllers, and event handlers. Total: 204 test files, 1178 tests passing. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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<typeof vi.fn>;
|
||||
findUnique: ReturnType<typeof vi.fn>;
|
||||
updateMany: ReturnType<typeof vi.fn>;
|
||||
deleteMany: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
};
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<typeof vi.fn>;
|
||||
create: ReturnType<typeof vi.fn>;
|
||||
update: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
};
|
||||
|
||||
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',
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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<typeof vi.fn> };
|
||||
let mockLogger: { warn: ReturnType<typeof vi.fn> };
|
||||
|
||||
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' }),
|
||||
],
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<typeof vi.fn>;
|
||||
findMany: ReturnType<typeof vi.fn>;
|
||||
count: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
};
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<typeof vi.fn> };
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<typeof vi.fn>;
|
||||
create: ReturnType<typeof vi.fn>;
|
||||
update: ReturnType<typeof vi.fn>;
|
||||
findMany: ReturnType<typeof vi.fn>;
|
||||
count: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
};
|
||||
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<typeof vi.fn> };
|
||||
let mockLogger: { log: ReturnType<typeof vi.fn>; warn: ReturnType<typeof vi.fn>; error: ReturnType<typeof vi.fn> };
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<typeof vi.fn>;
|
||||
};
|
||||
propertyMedia: {
|
||||
create: ReturnType<typeof vi.fn>;
|
||||
findMany: ReturnType<typeof vi.fn>;
|
||||
delete: ReturnType<typeof vi.fn>;
|
||||
count: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
$executeRaw: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
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' },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<typeof vi.fn> };
|
||||
let mockQueryBus: { execute: ReturnType<typeof vi.fn> };
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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('');
|
||||
});
|
||||
});
|
||||
@@ -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<typeof vi.fn>; removeListing: ReturnType<typeof vi.fn> };
|
||||
let mockCache: {
|
||||
invalidate: ReturnType<typeof vi.fn>;
|
||||
invalidateByPrefix: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
let mockLogger: { log: ReturnType<typeof vi.fn>; warn: ReturnType<typeof vi.fn>; error: ReturnType<typeof vi.fn> };
|
||||
|
||||
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),
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user