test(e2e): add 14 new web E2E test files for critical user flows

Cover auth (login, register, OAuth callbacks), search with filters,
listing detail, dashboard, analytics, create listing form, admin
dashboard/users/moderation/KYC, navigation routing, and responsive
design. Total 91 test cases using Playwright with API route mocking.

Also fix mcp-servers tsconfig deprecation warning for TS 7.x compat.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-08 13:13:46 +07:00
parent 91ef71d5e1
commit 9b2b8c2ba5
19 changed files with 1690 additions and 0 deletions

View File

@@ -0,0 +1,116 @@
import { ListingEntity } from '@modules/listings/domain/entities/listing.entity';
import { type IListingRepository } from '@modules/listings/domain/repositories/listing.repository';
import { type IPropertyRepository } from '@modules/listings/domain/repositories/property.repository';
import { CreateListingCommand } from '../commands/create-listing/create-listing.command';
import { CreateListingHandler } from '../commands/create-listing/create-listing.handler';
describe('CreateListingHandler', () => {
let handler: CreateListingHandler;
let mockPropertyRepo: { [K in keyof IPropertyRepository]: ReturnType<typeof vi.fn> };
let mockListingRepo: { [K in keyof IListingRepository]: ReturnType<typeof vi.fn> };
let mockEventBus: { publish: ReturnType<typeof vi.fn> };
let mockCache: { invalidateByPrefix: ReturnType<typeof vi.fn>; invalidate: ReturnType<typeof vi.fn>; getOrSet: ReturnType<typeof vi.fn> };
beforeEach(() => {
mockPropertyRepo = {
findById: vi.fn(),
save: vi.fn().mockResolvedValue(undefined),
update: vi.fn(),
addMedia: vi.fn(),
findMediaByPropertyId: vi.fn(),
deleteMedia: vi.fn(),
countMediaByPropertyId: vi.fn(),
};
mockListingRepo = {
findById: vi.fn(),
findByIdWithProperty: vi.fn(),
save: vi.fn().mockResolvedValue(undefined),
update: vi.fn(),
search: vi.fn(),
findByStatus: vi.fn(),
findBySellerId: vi.fn(),
};
mockEventBus = { publish: vi.fn() };
mockCache = {
invalidateByPrefix: vi.fn().mockResolvedValue(undefined),
invalidate: vi.fn(),
getOrSet: vi.fn(),
};
handler = new CreateListingHandler(
mockPropertyRepo as any,
mockListingRepo as any,
mockEventBus as any,
mockCache as any,
);
});
it('creates listing and property successfully', async () => {
const command = new CreateListingCommand(
'seller-1', 'SALE', 5_000_000_000n,
'APARTMENT', 'Căn hộ đẹp', 'Mô tả chi tiết',
'123 Nguyễn Huệ', 'Phường Bến Nghé', 'Quận 1', 'TP. Hồ Chí Minh',
10.7769, 106.7009, 80,
);
const result = await handler.execute(command);
expect(result.listingId).toBeDefined();
expect(result.propertyId).toBeDefined();
expect(result.status).toBe('DRAFT');
expect(mockPropertyRepo.save).toHaveBeenCalledTimes(1);
expect(mockListingRepo.save).toHaveBeenCalledTimes(1);
expect(mockEventBus.publish).toHaveBeenCalled();
expect(mockCache.invalidateByPrefix).toHaveBeenCalled();
});
it('creates listing with optional fields', async () => {
const command = new CreateListingCommand(
'seller-1', 'SALE', 3_000_000_000n,
'HOUSE', 'Nhà phố', 'Mô tả',
'456 Lê Lợi', 'Phường 1', 'Quận 3', 'TP. Hồ Chí Minh',
10.78, 106.69, 120,
100, 3, 2, 3, undefined, undefined, 'EAST', 2020, 'SỔ HỒNG',
);
const result = await handler.execute(command);
expect(result.listingId).toBeDefined();
expect(result.status).toBe('DRAFT');
});
it('throws ValidationException for invalid address', async () => {
const command = new CreateListingCommand(
'seller-1', 'SALE', 1_000_000_000n,
'APARTMENT', 'Test', 'Test',
'', '', '', '',
10.77, 106.70, 50,
);
await expect(handler.execute(command)).rejects.toThrow();
});
it('throws ValidationException for invalid price', async () => {
const command = new CreateListingCommand(
'seller-1', 'SALE', -100n,
'APARTMENT', 'Test', 'Test',
'123 ABC', 'Ward', 'District', 'City',
10.77, 106.70, 50,
);
await expect(handler.execute(command)).rejects.toThrow();
});
it('throws ValidationException for invalid geo coordinates', async () => {
const command = new CreateListingCommand(
'seller-1', 'SALE', 1_000_000_000n,
'APARTMENT', 'Test', 'Test',
'123 ABC', 'Ward', 'District', 'City',
999, 999, 50,
);
await expect(handler.execute(command)).rejects.toThrow();
});
});

View File

@@ -0,0 +1,73 @@
import { type IListingRepository } from '@modules/listings/domain/repositories/listing.repository';
import { GetListingQuery } from '../queries/get-listing/get-listing.query';
import { GetListingHandler } from '../queries/get-listing/get-listing.handler';
describe('GetListingHandler', () => {
let handler: GetListingHandler;
let mockListingRepo: { [K in keyof IListingRepository]: ReturnType<typeof vi.fn> };
let mockCache: { getOrSet: ReturnType<typeof vi.fn>; invalidate: ReturnType<typeof vi.fn>; invalidateByPrefix: ReturnType<typeof vi.fn> };
const mockListingDetail = {
id: 'listing-1',
status: 'ACTIVE',
price: 5_000_000_000n,
property: { id: 'prop-1', title: 'Căn hộ Q1' },
};
beforeEach(() => {
mockListingRepo = {
findById: vi.fn(),
findByIdWithProperty: vi.fn(),
save: vi.fn(),
update: vi.fn(),
search: vi.fn(),
findByStatus: vi.fn(),
findBySellerId: vi.fn(),
};
mockCache = {
getOrSet: vi.fn(),
invalidate: vi.fn(),
invalidateByPrefix: vi.fn(),
};
handler = new GetListingHandler(
mockListingRepo as any,
mockCache as any,
);
});
it('returns listing detail via cache', async () => {
mockCache.getOrSet.mockImplementation(async (_key: string, fn: () => Promise<unknown>) => fn());
mockListingRepo.findByIdWithProperty.mockResolvedValue(mockListingDetail);
const query = new GetListingQuery('listing-1');
const result = await handler.execute(query);
expect(result).toEqual(mockListingDetail);
expect(mockCache.getOrSet).toHaveBeenCalled();
});
it('throws NotFoundException when listing not found', async () => {
mockCache.getOrSet.mockImplementation(async (_key: string, fn: () => Promise<unknown>) => fn());
mockListingRepo.findByIdWithProperty.mockResolvedValue(null);
const query = new GetListingQuery('nonexistent');
await expect(handler.execute(query)).rejects.toThrow('Listing');
});
it('uses cache key with listing id', async () => {
mockCache.getOrSet.mockImplementation(async (_key: string, fn: () => Promise<unknown>) => fn());
mockListingRepo.findByIdWithProperty.mockResolvedValue(mockListingDetail);
await handler.execute(new GetListingQuery('listing-1'));
expect(mockCache.getOrSet).toHaveBeenCalledWith(
expect.stringContaining('listing-1'),
expect.any(Function),
expect.anything(),
expect.anything(),
);
});
});

View File

@@ -0,0 +1,61 @@
import { type IListingRepository } from '@modules/listings/domain/repositories/listing.repository';
import { GetPendingModerationQuery } from '../queries/get-pending-moderation/get-pending-moderation.query';
import { GetPendingModerationHandler } from '../queries/get-pending-moderation/get-pending-moderation.handler';
describe('GetPendingModerationHandler', () => {
let handler: GetPendingModerationHandler;
let mockListingRepo: { [K in keyof IListingRepository]: ReturnType<typeof vi.fn> };
beforeEach(() => {
mockListingRepo = {
findById: vi.fn(),
findByIdWithProperty: vi.fn(),
save: vi.fn(),
update: vi.fn(),
search: vi.fn(),
findByStatus: vi.fn(),
findBySellerId: vi.fn(),
};
handler = new GetPendingModerationHandler(mockListingRepo as any);
});
it('returns paginated pending listings', async () => {
const mockResult = {
data: [{ id: 'listing-1', status: 'PENDING_REVIEW' }],
total: 1,
page: 1,
limit: 20,
totalPages: 1,
};
mockListingRepo.findByStatus.mockResolvedValue(mockResult);
const query = new GetPendingModerationQuery(1, 20);
const result = await handler.execute(query);
expect(result).toEqual(mockResult);
expect(mockListingRepo.findByStatus).toHaveBeenCalledWith('PENDING_REVIEW', 1, 20);
});
it('uses default pagination values', async () => {
const mockResult = { data: [], total: 0, page: 1, limit: 20, totalPages: 0 };
mockListingRepo.findByStatus.mockResolvedValue(mockResult);
const query = new GetPendingModerationQuery();
await handler.execute(query);
expect(mockListingRepo.findByStatus).toHaveBeenCalledWith('PENDING_REVIEW', 1, 20);
});
it('passes custom page and limit', async () => {
const mockResult = { data: [], total: 50, page: 3, limit: 10, totalPages: 5 };
mockListingRepo.findByStatus.mockResolvedValue(mockResult);
const query = new GetPendingModerationQuery(3, 10);
const result = await handler.execute(query);
expect(result.page).toBe(3);
expect(result.limit).toBe(10);
expect(mockListingRepo.findByStatus).toHaveBeenCalledWith('PENDING_REVIEW', 3, 10);
});
});

View File

@@ -0,0 +1,96 @@
import { ListingEntity } from '@modules/listings/domain/entities/listing.entity';
import { type IListingRepository } from '@modules/listings/domain/repositories/listing.repository';
import { Price } from '@modules/listings/domain/value-objects/price.vo';
import { ModerateListingCommand } from '../commands/moderate-listing/moderate-listing.command';
import { ModerateListingHandler } from '../commands/moderate-listing/moderate-listing.handler';
function createPendingListing(id = 'listing-1'): ListingEntity {
const price = Price.create(1_000_000_000n).unwrap();
const listing = ListingEntity.createNew(id, 'prop-1', 'seller-1', 'SALE', price, 100);
listing.submitForReview();
listing.clearDomainEvents();
return listing;
}
describe('ModerateListingHandler', () => {
let handler: ModerateListingHandler;
let mockListingRepo: { [K in keyof IListingRepository]: ReturnType<typeof vi.fn> };
let mockEventBus: { publish: ReturnType<typeof vi.fn> };
let mockCache: { invalidate: ReturnType<typeof vi.fn>; invalidateByPrefix: ReturnType<typeof vi.fn> };
beforeEach(() => {
mockListingRepo = {
findById: vi.fn(),
findByIdWithProperty: vi.fn(),
save: vi.fn(),
update: vi.fn().mockResolvedValue(undefined),
search: vi.fn(),
findByStatus: vi.fn(),
findBySellerId: vi.fn(),
};
mockEventBus = { publish: vi.fn() };
mockCache = {
invalidate: vi.fn().mockResolvedValue(undefined),
invalidateByPrefix: vi.fn().mockResolvedValue(undefined),
};
handler = new ModerateListingHandler(
mockListingRepo as any,
mockEventBus as any,
mockCache as any,
);
});
it('approves a pending listing', async () => {
const listing = createPendingListing();
mockListingRepo.findById.mockResolvedValue(listing);
const command = new ModerateListingCommand('listing-1', 'mod-1', 'approve');
const result = await handler.execute(command);
expect(result.status).toBe('ACTIVE');
expect(mockListingRepo.update).toHaveBeenCalledTimes(1);
expect(mockEventBus.publish).toHaveBeenCalled();
});
it('rejects a pending listing', async () => {
const listing = createPendingListing();
mockListingRepo.findById.mockResolvedValue(listing);
const command = new ModerateListingCommand('listing-1', 'mod-1', 'reject', undefined, 'Nội dung không phù hợp');
const result = await handler.execute(command);
expect(result.status).toBe('REJECTED');
expect(mockListingRepo.update).toHaveBeenCalledTimes(1);
});
it('sets moderation score before action', async () => {
const listing = createPendingListing();
mockListingRepo.findById.mockResolvedValue(listing);
const command = new ModerateListingCommand('listing-1', 'mod-1', 'approve', 95, 'Chất lượng tốt');
await handler.execute(command);
expect(listing.moderationScore).toBe(95);
});
it('throws NotFoundException when listing does not exist', async () => {
mockListingRepo.findById.mockResolvedValue(null);
const command = new ModerateListingCommand('nonexistent', 'mod-1', 'approve');
await expect(handler.execute(command)).rejects.toThrow('Listing');
});
it('invalidates cache after moderation', async () => {
const listing = createPendingListing();
mockListingRepo.findById.mockResolvedValue(listing);
const command = new ModerateListingCommand('listing-1', 'mod-1', 'approve');
await handler.execute(command);
expect(mockCache.invalidate).toHaveBeenCalled();
expect(mockCache.invalidateByPrefix).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,85 @@
import { type IListingRepository } from '@modules/listings/domain/repositories/listing.repository';
import { SearchListingsQuery } from '../queries/search-listings/search-listings.query';
import { SearchListingsHandler } from '../queries/search-listings/search-listings.handler';
describe('SearchListingsHandler', () => {
let handler: SearchListingsHandler;
let mockListingRepo: { [K in keyof IListingRepository]: ReturnType<typeof vi.fn> };
beforeEach(() => {
mockListingRepo = {
findById: vi.fn(),
findByIdWithProperty: vi.fn(),
save: vi.fn(),
update: vi.fn(),
search: vi.fn(),
findByStatus: vi.fn(),
findBySellerId: vi.fn(),
};
handler = new SearchListingsHandler(mockListingRepo as any);
});
it('searches with all filters', async () => {
const mockResult = {
data: [{ id: 'listing-1', status: 'ACTIVE' }],
total: 1,
page: 1,
limit: 20,
totalPages: 1,
};
mockListingRepo.search.mockResolvedValue(mockResult);
const query = new SearchListingsQuery(
'ACTIVE', 'SALE', 'APARTMENT', 'TP. Hồ Chí Minh', 'Quận 1',
1_000_000_000n, 10_000_000_000n, 50, 200, 2,
);
const result = await handler.execute(query);
expect(result).toEqual(mockResult);
expect(mockListingRepo.search).toHaveBeenCalledWith({
status: 'ACTIVE',
transactionType: 'SALE',
propertyType: 'APARTMENT',
city: 'TP. Hồ Chí Minh',
district: 'Quận 1',
minPrice: 1_000_000_000n,
maxPrice: 10_000_000_000n,
minArea: 50,
maxArea: 200,
bedrooms: 2,
page: 1,
limit: 20,
});
});
it('searches with no filters (defaults)', async () => {
const mockResult = { data: [], total: 0, page: 1, limit: 20, totalPages: 0 };
mockListingRepo.search.mockResolvedValue(mockResult);
const query = new SearchListingsQuery();
const result = await handler.execute(query);
expect(result).toEqual(mockResult);
expect(mockListingRepo.search).toHaveBeenCalledWith(
expect.objectContaining({ page: 1, limit: 20 }),
);
});
it('passes custom pagination', async () => {
const mockResult = { data: [], total: 100, page: 5, limit: 10, totalPages: 10 };
mockListingRepo.search.mockResolvedValue(mockResult);
const query = new SearchListingsQuery(
undefined, undefined, undefined, undefined, undefined,
undefined, undefined, undefined, undefined, undefined,
5, 10,
);
const result = await handler.execute(query);
expect(result.page).toBe(5);
expect(mockListingRepo.search).toHaveBeenCalledWith(
expect.objectContaining({ page: 5, limit: 10 }),
);
});
});

View File

@@ -0,0 +1,97 @@
import { ListingEntity } from '@modules/listings/domain/entities/listing.entity';
import { type IListingRepository } from '@modules/listings/domain/repositories/listing.repository';
import { Price } from '@modules/listings/domain/value-objects/price.vo';
import { UpdateListingStatusCommand } from '../commands/update-listing-status/update-listing-status.command';
import { UpdateListingStatusHandler } from '../commands/update-listing-status/update-listing-status.handler';
function createListing(id = 'listing-1', status: 'DRAFT' | 'PENDING_REVIEW' | 'ACTIVE' = 'DRAFT'): ListingEntity {
const price = Price.create(2_000_000_000n).unwrap();
const listing = ListingEntity.createNew(id, 'prop-1', 'seller-1', 'SALE', price, 80);
if (status === 'PENDING_REVIEW') listing.submitForReview();
if (status === 'ACTIVE') {
listing.submitForReview();
listing.approve();
}
listing.clearDomainEvents();
return listing;
}
describe('UpdateListingStatusHandler', () => {
let handler: UpdateListingStatusHandler;
let mockListingRepo: { [K in keyof IListingRepository]: ReturnType<typeof vi.fn> };
let mockEventBus: { publish: ReturnType<typeof vi.fn> };
let mockCache: { invalidate: ReturnType<typeof vi.fn>; invalidateByPrefix: ReturnType<typeof vi.fn> };
beforeEach(() => {
mockListingRepo = {
findById: vi.fn(),
findByIdWithProperty: vi.fn(),
save: vi.fn(),
update: vi.fn().mockResolvedValue(undefined),
search: vi.fn(),
findByStatus: vi.fn(),
findBySellerId: vi.fn(),
};
mockEventBus = { publish: vi.fn() };
mockCache = {
invalidate: vi.fn().mockResolvedValue(undefined),
invalidateByPrefix: vi.fn().mockResolvedValue(undefined),
};
handler = new UpdateListingStatusHandler(
mockListingRepo as any,
mockEventBus as any,
mockCache as any,
);
});
it('approves a pending listing via ACTIVE status', async () => {
const listing = createListing('listing-1', 'PENDING_REVIEW');
mockListingRepo.findById.mockResolvedValue(listing);
const command = new UpdateListingStatusCommand('listing-1', 'ACTIVE', 'admin-1');
const result = await handler.execute(command);
expect(result.status).toBe('ACTIVE');
expect(mockListingRepo.update).toHaveBeenCalledTimes(1);
expect(mockEventBus.publish).toHaveBeenCalled();
});
it('rejects a listing with moderation notes', async () => {
const listing = createListing('listing-1', 'PENDING_REVIEW');
mockListingRepo.findById.mockResolvedValue(listing);
const command = new UpdateListingStatusCommand('listing-1', 'REJECTED', 'admin-1', 'Vi phạm chính sách');
const result = await handler.execute(command);
expect(result.status).toBe('REJECTED');
});
it('transitions active listing to SOLD', async () => {
const listing = createListing('listing-1', 'ACTIVE');
mockListingRepo.findById.mockResolvedValue(listing);
const command = new UpdateListingStatusCommand('listing-1', 'SOLD', 'seller-1');
const result = await handler.execute(command);
expect(result.status).toBe('SOLD');
});
it('throws NotFoundException for non-existent listing', async () => {
mockListingRepo.findById.mockResolvedValue(null);
const command = new UpdateListingStatusCommand('nonexistent', 'ACTIVE', 'admin-1');
await expect(handler.execute(command)).rejects.toThrow('Listing');
});
it('throws ValidationException for invalid status transition', async () => {
const listing = createListing('listing-1', 'DRAFT');
mockListingRepo.findById.mockResolvedValue(listing);
const command = new UpdateListingStatusCommand('listing-1', 'SOLD', 'seller-1');
await expect(handler.execute(command)).rejects.toThrow(/trạng thái/);
});
});

View File

@@ -0,0 +1,98 @@
import { type IPropertyRepository } from '@modules/listings/domain/repositories/property.repository';
import { type IMediaStorageService } from '@modules/listings/infrastructure/services/media-storage.service';
import { UploadMediaCommand } from '../commands/upload-media/upload-media.command';
import { UploadMediaHandler } from '../commands/upload-media/upload-media.handler';
describe('UploadMediaHandler', () => {
let handler: UploadMediaHandler;
let mockPropertyRepo: { [K in keyof IPropertyRepository]: ReturnType<typeof vi.fn> };
let mockMediaStorage: { [K in keyof IMediaStorageService]: ReturnType<typeof vi.fn> };
beforeEach(() => {
mockPropertyRepo = {
findById: vi.fn(),
save: vi.fn(),
update: vi.fn(),
addMedia: vi.fn().mockResolvedValue(undefined),
findMediaByPropertyId: vi.fn(),
deleteMedia: vi.fn(),
countMediaByPropertyId: vi.fn(),
};
mockMediaStorage = {
upload: vi.fn().mockResolvedValue('http://storage.local/media/test.jpg'),
delete: vi.fn(),
};
handler = new UploadMediaHandler(
mockPropertyRepo as any,
mockMediaStorage as any,
);
});
it('uploads image media successfully', async () => {
mockPropertyRepo.findById.mockResolvedValue({ id: 'prop-1' });
mockPropertyRepo.countMediaByPropertyId.mockResolvedValue(5);
const command = new UploadMediaCommand('prop-1', 'user-1', {
buffer: Buffer.from('fake-image'),
mimetype: 'image/jpeg',
originalname: 'photo.jpg',
size: 1024,
}, 'Phòng khách');
const result = await handler.execute(command);
expect(result.mediaId).toBeDefined();
expect(result.url).toBe('http://storage.local/media/test.jpg');
expect(mockMediaStorage.upload).toHaveBeenCalledWith(
expect.any(Buffer), 'photo.jpg', 'image/jpeg', 'properties/prop-1',
);
expect(mockPropertyRepo.addMedia).toHaveBeenCalledTimes(1);
});
it('uploads video media with correct type', async () => {
mockPropertyRepo.findById.mockResolvedValue({ id: 'prop-1' });
mockPropertyRepo.countMediaByPropertyId.mockResolvedValue(0);
mockMediaStorage.upload.mockResolvedValue('http://storage.local/media/video.mp4');
const command = new UploadMediaCommand('prop-1', 'user-1', {
buffer: Buffer.from('fake-video'),
mimetype: 'video/mp4',
originalname: 'tour.mp4',
size: 10240,
});
const result = await handler.execute(command);
expect(result.mediaId).toBeDefined();
expect(result.url).toBe('http://storage.local/media/video.mp4');
});
it('throws NotFoundException when property does not exist', async () => {
mockPropertyRepo.findById.mockResolvedValue(null);
const command = new UploadMediaCommand('nonexistent', 'user-1', {
buffer: Buffer.from('data'),
mimetype: 'image/png',
originalname: 'pic.png',
size: 512,
});
await expect(handler.execute(command)).rejects.toThrow('Property');
});
it('throws ValidationException when media limit exceeded', async () => {
mockPropertyRepo.findById.mockResolvedValue({ id: 'prop-1' });
mockPropertyRepo.countMediaByPropertyId.mockResolvedValue(20);
const command = new UploadMediaCommand('prop-1', 'user-1', {
buffer: Buffer.from('data'),
mimetype: 'image/jpeg',
originalname: 'pic.jpg',
size: 512,
});
await expect(handler.execute(command)).rejects.toThrow(/20/);
});
});