feat: add MFA/TOTP auth, PII encryption, agents/leads/inquiries modules, and comprehensive tests
- Add TOTP-based MFA with setup, verify, disable, backup codes, and challenge flow - Add PII field encryption middleware with AES-256-GCM and deterministic search hashes - Add agents, inquiries, and leads domain modules with entities, events, value objects - Add web dashboard pages for inquiries and leads with detail dialogs - Add 30+ component tests (valuation, charts, listings, search, providers, UI) - Add Prisma migrations for encryption hash columns and MFA TOTP support - Fix all ESLint errors (unused imports, duplicate imports, lint auto-fixes) - Update dependencies and lock file - Clean up obsolete exploration/QA docs, add audit documentation Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
127
apps/web/components/listings/__tests__/image-gallery.spec.tsx
Normal file
127
apps/web/components/listings/__tests__/image-gallery.spec.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { PropertyMedia } from '@/lib/listings-api';
|
||||
import { ImageGallery } from '../image-gallery';
|
||||
|
||||
// Mock next/image
|
||||
vi.mock('next/image', () => ({
|
||||
default: (props: Record<string, unknown>) => <img {...props} />,
|
||||
}));
|
||||
|
||||
// Mock scrollIntoView (not available in jsdom)
|
||||
Element.prototype.scrollIntoView = vi.fn();
|
||||
|
||||
// Mock ImageLightbox
|
||||
vi.mock('@/components/listings/image-lightbox', () => ({
|
||||
ImageLightbox: ({ open }: { open: boolean }) =>
|
||||
open ? <div data-testid="lightbox">Lightbox</div> : null,
|
||||
}));
|
||||
|
||||
// Mock image-blur
|
||||
vi.mock('@/lib/image-blur', () => ({
|
||||
shimmerBlurDataURL: () => 'data:image/svg+xml;base64,mock',
|
||||
}));
|
||||
|
||||
function makeMedia(count: number): PropertyMedia[] {
|
||||
return Array.from({ length: count }, (_, i) => ({
|
||||
id: `media-${i}`,
|
||||
type: 'image' as const,
|
||||
url: `https://example.com/img${i}.jpg`,
|
||||
order: i,
|
||||
caption: i === 0 ? 'Main photo' : null,
|
||||
}));
|
||||
}
|
||||
|
||||
describe('ImageGallery', () => {
|
||||
it('shows "Chưa có hình ảnh" when no media', () => {
|
||||
render(<ImageGallery media={[]} />);
|
||||
expect(screen.getByText('Chưa có hình ảnh')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders main image when media exists', () => {
|
||||
render(<ImageGallery media={makeMedia(1)} />);
|
||||
const img = screen.getByRole('img');
|
||||
expect(img).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render thumbnails for single image', () => {
|
||||
render(<ImageGallery media={makeMedia(1)} />);
|
||||
// Single image - no thumbnail strip
|
||||
const imgs = screen.getAllByRole('img');
|
||||
expect(imgs).toHaveLength(1); // Only the main image
|
||||
});
|
||||
|
||||
it('renders thumbnails for multiple images', () => {
|
||||
render(<ImageGallery media={makeMedia(3)} />);
|
||||
// 1 main + 3 thumbnails = 4 images
|
||||
const imgs = screen.getAllByRole('img');
|
||||
expect(imgs.length).toBeGreaterThanOrEqual(4);
|
||||
});
|
||||
|
||||
it('renders navigation arrows for multiple images', () => {
|
||||
render(<ImageGallery media={makeMedia(3)} />);
|
||||
expect(screen.getByLabelText('Ảnh trước')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Ảnh tiếp')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render navigation arrows for single image', () => {
|
||||
render(<ImageGallery media={makeMedia(1)} />);
|
||||
expect(screen.queryByLabelText('Ảnh trước')).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('Ảnh tiếp')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows image counter', () => {
|
||||
render(<ImageGallery media={makeMedia(5)} />);
|
||||
expect(screen.getByText('1 / 5')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('navigates to next image when arrow is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ImageGallery media={makeMedia(3)} />);
|
||||
|
||||
await user.click(screen.getByLabelText('Ảnh tiếp'));
|
||||
expect(screen.getByText('2 / 3')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('navigates to previous image', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ImageGallery media={makeMedia(3)} />);
|
||||
|
||||
// Go forward then back
|
||||
await user.click(screen.getByLabelText('Ảnh tiếp'));
|
||||
await user.click(screen.getByLabelText('Ảnh trước'));
|
||||
expect(screen.getByText('1 / 3')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('wraps around to last image when pressing prev on first', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ImageGallery media={makeMedia(3)} />);
|
||||
|
||||
await user.click(screen.getByLabelText('Ảnh trước'));
|
||||
expect(screen.getByText('3 / 3')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows fullscreen button', () => {
|
||||
render(<ImageGallery media={makeMedia(2)} />);
|
||||
expect(screen.getByLabelText('Xem ảnh toàn màn hình')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens lightbox on fullscreen button click', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ImageGallery media={makeMedia(2)} />);
|
||||
|
||||
await user.click(screen.getByLabelText('Xem ảnh toàn màn hình'));
|
||||
expect(screen.getByTestId('lightbox')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('filters out non-image media', () => {
|
||||
const media: PropertyMedia[] = [
|
||||
{ id: 'img-1', type: 'image', url: 'https://example.com/img.jpg', order: 0, caption: null },
|
||||
{ id: 'vid-1', type: 'video' as PropertyMedia['type'], url: 'https://example.com/vid.mp4', order: 1, caption: null },
|
||||
];
|
||||
render(<ImageGallery media={media} />);
|
||||
// Should only render 1 image (main), no nav arrows for single image
|
||||
expect(screen.queryByLabelText('Ảnh trước')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
113
apps/web/components/listings/__tests__/image-lightbox.spec.tsx
Normal file
113
apps/web/components/listings/__tests__/image-lightbox.spec.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { PropertyMedia } from '@/lib/listings-api';
|
||||
import { ImageLightbox } from '../image-lightbox';
|
||||
|
||||
// Mock next/image
|
||||
vi.mock('next/image', () => ({
|
||||
default: (props: Record<string, unknown>) => <img {...props} />,
|
||||
}));
|
||||
|
||||
// Mock scrollIntoView (not available in jsdom)
|
||||
Element.prototype.scrollIntoView = vi.fn();
|
||||
|
||||
function makeImages(count: number): PropertyMedia[] {
|
||||
return Array.from({ length: count }, (_, i) => ({
|
||||
id: `img-${i}`,
|
||||
type: 'image' as const,
|
||||
url: `https://example.com/img${i}.jpg`,
|
||||
order: i,
|
||||
caption: i === 0 ? 'First photo' : null,
|
||||
}));
|
||||
}
|
||||
|
||||
describe('ImageLightbox', () => {
|
||||
it('returns null when not open', () => {
|
||||
const { container } = render(
|
||||
<ImageLightbox images={makeImages(3)} open={false} onClose={vi.fn()} />,
|
||||
);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when images are empty', () => {
|
||||
const { container } = render(
|
||||
<ImageLightbox images={[]} open={true} onClose={vi.fn()} />,
|
||||
);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('renders when open with images', () => {
|
||||
render(<ImageLightbox images={makeImages(3)} open={true} onClose={vi.fn()} />);
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('has correct aria-label', () => {
|
||||
render(<ImageLightbox images={makeImages(3)} open={true} onClose={vi.fn()} />);
|
||||
expect(screen.getByLabelText('Xem ảnh toàn màn hình')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows image counter', () => {
|
||||
render(<ImageLightbox images={makeImages(5)} open={true} onClose={vi.fn()} />);
|
||||
expect(screen.getByText('1 / 5')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows caption when present', () => {
|
||||
render(<ImageLightbox images={makeImages(3)} open={true} onClose={vi.fn()} />);
|
||||
expect(screen.getByText('First photo')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders close button', () => {
|
||||
render(<ImageLightbox images={makeImages(3)} open={true} onClose={vi.fn()} />);
|
||||
expect(screen.getByLabelText('Đóng (Escape)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onClose when close button clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClose = vi.fn();
|
||||
render(<ImageLightbox images={makeImages(3)} open={true} onClose={onClose} />);
|
||||
|
||||
await user.click(screen.getByLabelText('Đóng (Escape)'));
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders navigation arrows for multiple images', () => {
|
||||
render(<ImageLightbox images={makeImages(3)} open={true} onClose={vi.fn()} />);
|
||||
expect(screen.getByLabelText(/Ảnh trước/)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/Ảnh tiếp/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render arrows for single image', () => {
|
||||
render(<ImageLightbox images={makeImages(1)} open={true} onClose={vi.fn()} />);
|
||||
expect(screen.queryByLabelText(/Ảnh trước/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText(/Ảnh tiếp/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders thumbnail strip for multiple images', () => {
|
||||
render(<ImageLightbox images={makeImages(4)} open={true} onClose={vi.fn()} />);
|
||||
const tablist = screen.getByRole('tablist');
|
||||
expect(tablist).toBeInTheDocument();
|
||||
expect(screen.getAllByRole('tab')).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('first thumbnail is selected by default', () => {
|
||||
render(<ImageLightbox images={makeImages(3)} open={true} onClose={vi.fn()} />);
|
||||
const tabs = screen.getAllByRole('tab');
|
||||
expect(tabs[0]).toHaveAttribute('aria-selected', 'true');
|
||||
});
|
||||
|
||||
it('navigates to next image when arrow clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ImageLightbox images={makeImages(3)} open={true} onClose={vi.fn()} />);
|
||||
|
||||
await user.click(screen.getByLabelText(/Ảnh tiếp/));
|
||||
expect(screen.getByText('2 / 3')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('uses initialIndex prop', () => {
|
||||
render(
|
||||
<ImageLightbox images={makeImages(5)} open={true} onClose={vi.fn()} initialIndex={2} />,
|
||||
);
|
||||
expect(screen.getByText('3 / 5')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
74
apps/web/components/listings/__tests__/image-upload.spec.tsx
Normal file
74
apps/web/components/listings/__tests__/image-upload.spec.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { ImageUpload, type ImageFile } from '../image-upload';
|
||||
|
||||
function createMockImageFile(name = 'test.jpg'): ImageFile {
|
||||
const file = new File(['content'], name, { type: 'image/jpeg' });
|
||||
return { file, preview: `blob:${name}` };
|
||||
}
|
||||
|
||||
describe('ImageUpload', () => {
|
||||
it('renders drop zone with instructions', () => {
|
||||
render(<ImageUpload images={[]} onChange={vi.fn()} />);
|
||||
expect(screen.getByText('Kéo thả ảnh vào đây hoặc nhấp để chọn')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders max files hint', () => {
|
||||
render(<ImageUpload images={[]} onChange={vi.fn()} maxFiles={10} />);
|
||||
expect(screen.getByText(/Tối đa 10 ảnh/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders default max files hint (20)', () => {
|
||||
render(<ImageUpload images={[]} onChange={vi.fn()} />);
|
||||
expect(screen.getByText(/Tối đa 20 ảnh/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders image previews when images are provided', () => {
|
||||
const images = [createMockImageFile('img1.jpg'), createMockImageFile('img2.jpg')];
|
||||
render(<ImageUpload images={images} onChange={vi.fn()} />);
|
||||
const imgElements = screen.getAllByRole('img');
|
||||
expect(imgElements).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('shows "Ảnh bìa" badge on first image', () => {
|
||||
const images = [createMockImageFile()];
|
||||
render(<ImageUpload images={images} onChange={vi.fn()} />);
|
||||
expect(screen.getByText('Ảnh bìa')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows delete button on hover (rendered)', () => {
|
||||
const images = [createMockImageFile()];
|
||||
render(<ImageUpload images={images} onChange={vi.fn()} />);
|
||||
expect(screen.getByText('Xóa')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onChange when delete button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
const images = [createMockImageFile('img1.jpg'), createMockImageFile('img2.jpg')];
|
||||
render(<ImageUpload images={images} onChange={onChange} />);
|
||||
|
||||
const deleteButtons = screen.getAllByText('Xóa');
|
||||
await user.click(deleteButtons[0]!);
|
||||
expect(onChange).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('has accessible drop zone with aria-label', () => {
|
||||
render(<ImageUpload images={[]} onChange={vi.fn()} />);
|
||||
expect(screen.getByLabelText(/Tải ảnh lên/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders hidden file input', () => {
|
||||
render(<ImageUpload images={[]} onChange={vi.fn()} />);
|
||||
const fileInput = document.querySelector('input[type="file"]');
|
||||
expect(fileInput).toBeInTheDocument();
|
||||
expect(fileInput).toHaveClass('hidden');
|
||||
});
|
||||
|
||||
it('accepts correct file types', () => {
|
||||
render(<ImageUpload images={[]} onChange={vi.fn()} />);
|
||||
const fileInput = document.querySelector('input[type="file"]');
|
||||
expect(fileInput).toHaveAttribute('accept', 'image/jpeg,image/png,image/webp');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,222 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { ListingDetail } from '@/lib/listings-api';
|
||||
import { ListingDetailClient } from '../listing-detail-client';
|
||||
|
||||
// Mock next/image
|
||||
vi.mock('next/image', () => ({
|
||||
default: (props: Record<string, unknown>) => <img {...props} />,
|
||||
}));
|
||||
|
||||
// Mock next/link
|
||||
vi.mock('next/link', () => ({
|
||||
default: ({ children, href }: { children: React.ReactNode; href: string }) => (
|
||||
<a href={href}>{children}</a>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock next/dynamic to render children directly
|
||||
vi.mock('next/dynamic', () => ({
|
||||
default: () => {
|
||||
const DynamicComponent = () => <div data-testid="listing-map">Map placeholder</div>;
|
||||
DynamicComponent.displayName = 'DynamicListingMap';
|
||||
return DynamicComponent;
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock AddToCompareButton
|
||||
vi.mock('@/components/comparison/add-to-compare-button', () => ({
|
||||
AddToCompareButton: ({ listingId }: { listingId: string }) => (
|
||||
<button data-testid={`compare-btn-${listingId}`}>Compare</button>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock ImageGallery
|
||||
vi.mock('@/components/listings/image-gallery', () => ({
|
||||
ImageGallery: () => <div data-testid="image-gallery">Gallery</div>,
|
||||
}));
|
||||
|
||||
// Mock AiEstimateButton
|
||||
vi.mock('@/components/valuation/ai-estimate-button', () => ({
|
||||
AiEstimateButton: ({ listingId }: { listingId: string }) => (
|
||||
<button data-testid={`ai-estimate-${listingId}`}>AI Estimate</button>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock currency
|
||||
vi.mock('@/lib/currency', () => ({
|
||||
formatPrice: (price: string) => {
|
||||
const n = Number(price);
|
||||
if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(1)} tỷ`;
|
||||
return n.toLocaleString('vi-VN');
|
||||
},
|
||||
formatPricePerM2: (price: number) => `${(price / 1_000_000).toFixed(1)} tr/m²`,
|
||||
}));
|
||||
|
||||
function makeListing(overrides: Partial<ListingDetail> = {}): ListingDetail {
|
||||
return {
|
||||
id: 'listing-1',
|
||||
status: 'ACTIVE',
|
||||
transactionType: 'SALE',
|
||||
priceVND: '3500000000',
|
||||
pricePerM2: 40_000_000,
|
||||
rentPriceMonthly: null,
|
||||
commissionPct: null,
|
||||
viewCount: 100,
|
||||
saveCount: 10,
|
||||
inquiryCount: 5,
|
||||
publishedAt: '2026-01-01T00:00:00Z',
|
||||
createdAt: '2025-12-01T00:00:00Z',
|
||||
property: {
|
||||
id: 'prop-1',
|
||||
propertyType: 'APARTMENT',
|
||||
title: 'Căn hộ 2PN Vinhomes Central Park',
|
||||
description: 'Căn hộ đẹp view sông Sài Gòn',
|
||||
address: '208 Nguyễn Hữu Cảnh',
|
||||
ward: 'Phường 22',
|
||||
district: 'Quận Bình Thạnh',
|
||||
city: 'Hồ Chí Minh',
|
||||
areaM2: 75,
|
||||
bedrooms: 2,
|
||||
bathrooms: 2,
|
||||
floors: null,
|
||||
direction: 'SOUTH',
|
||||
yearBuilt: 2020,
|
||||
legalStatus: 'Sổ hồng',
|
||||
amenities: ['Hồ bơi', 'Gym'],
|
||||
projectName: 'Vinhomes Central Park',
|
||||
latitude: 10.7975,
|
||||
longitude: 106.721,
|
||||
media: [
|
||||
{ id: 'media-1', type: 'image', url: 'https://example.com/img1.jpg', order: 0, caption: null },
|
||||
],
|
||||
},
|
||||
seller: {
|
||||
id: 'seller-1',
|
||||
fullName: 'Nguyen Van B',
|
||||
phone: '0912345678',
|
||||
},
|
||||
agent: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('ListingDetailClient', () => {
|
||||
it('renders property title', () => {
|
||||
render(<ListingDetailClient listing={makeListing()} />);
|
||||
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Căn hộ 2PN Vinhomes Central Park');
|
||||
});
|
||||
|
||||
it('renders formatted price', () => {
|
||||
render(<ListingDetailClient listing={makeListing()} />);
|
||||
expect(screen.getByText(/3\.5 tỷ VND/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders property address', () => {
|
||||
render(<ListingDetailClient listing={makeListing()} />);
|
||||
expect(screen.getByText(/208 Nguyễn Hữu Cảnh/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders transaction type badge', () => {
|
||||
render(<ListingDetailClient listing={makeListing()} />);
|
||||
expect(screen.getByText('Bán')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders property type badge', () => {
|
||||
render(<ListingDetailClient listing={makeListing()} />);
|
||||
// "Căn hộ" appears in badge, title, description, and detail row — use getAllByText
|
||||
const matches = screen.getAllByText(/Căn hộ/);
|
||||
expect(matches.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('renders area in quick stats', () => {
|
||||
render(<ListingDetailClient listing={makeListing()} />);
|
||||
// "75 m²" may appear in multiple places (quick stats and detail row)
|
||||
const matches = screen.getAllByText(/75 m/);
|
||||
expect(matches.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('renders bedrooms in quick stats', () => {
|
||||
render(<ListingDetailClient listing={makeListing()} />);
|
||||
// Bedrooms value is displayed in the quick stats
|
||||
const allText = document.body.textContent;
|
||||
expect(allText).toContain('2');
|
||||
});
|
||||
|
||||
it('renders description section', () => {
|
||||
render(<ListingDetailClient listing={makeListing()} />);
|
||||
expect(screen.getByText('Mô tả')).toBeInTheDocument();
|
||||
expect(screen.getByText('Căn hộ đẹp view sông Sài Gòn')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders seller contact info', () => {
|
||||
render(<ListingDetailClient listing={makeListing()} />);
|
||||
expect(screen.getByText('Nguyen Van B')).toBeInTheDocument();
|
||||
expect(screen.getByText('0912345678')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Gọi ngay button', () => {
|
||||
render(<ListingDetailClient listing={makeListing()} />);
|
||||
expect(screen.getByText('Gọi ngay')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders view/save/inquiry stats', () => {
|
||||
render(<ListingDetailClient listing={makeListing()} />);
|
||||
expect(screen.getByText('100')).toBeInTheDocument(); // viewCount
|
||||
expect(screen.getByText('10')).toBeInTheDocument(); // saveCount
|
||||
expect(screen.getByText('5')).toBeInTheDocument(); // inquiryCount
|
||||
});
|
||||
|
||||
it('renders amenities when present', () => {
|
||||
render(<ListingDetailClient listing={makeListing()} />);
|
||||
expect(screen.getByText('Hồ bơi')).toBeInTheDocument();
|
||||
expect(screen.getByText('Gym')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render amenities section when empty', () => {
|
||||
const listing = makeListing();
|
||||
listing.property.amenities = [];
|
||||
render(<ListingDetailClient listing={listing} />);
|
||||
expect(screen.queryByText('Tiện ích')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders rent price when present', () => {
|
||||
render(<ListingDetailClient listing={makeListing({ rentPriceMonthly: '15000000' })} />);
|
||||
// Rent text contains monthly rent info
|
||||
const content = document.body.textContent ?? '';
|
||||
// Check for the rent amount - format may vary; at minimum the number should appear
|
||||
expect(content).toMatch(/15[.,]000[.,]000/);
|
||||
});
|
||||
|
||||
it('renders breadcrumb navigation', () => {
|
||||
render(<ListingDetailClient listing={makeListing()} />);
|
||||
// Breadcrumb uses unicode chars
|
||||
const nav = screen.getByRole('navigation');
|
||||
expect(nav).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders compare button', () => {
|
||||
render(<ListingDetailClient listing={makeListing()} />);
|
||||
expect(screen.getByTestId('compare-btn-listing-1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders AI estimate button', () => {
|
||||
render(<ListingDetailClient listing={makeListing()} />);
|
||||
expect(screen.getByTestId('ai-estimate-listing-1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders agent commission info when present', () => {
|
||||
const listing = makeListing({
|
||||
agent: { agency: 'Công ty ABC', id: 'agent-1' } as ListingDetail['agent'],
|
||||
commissionPct: 2.5,
|
||||
});
|
||||
render(<ListingDetailClient listing={listing} />);
|
||||
expect(screen.getByText('Công ty ABC')).toBeInTheDocument();
|
||||
expect(screen.getByText('Hoa hồng: 2.5%')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders published date', () => {
|
||||
render(<ListingDetailClient listing={makeListing()} />);
|
||||
expect(screen.getByText(/Đăng ngày/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,204 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import type { UseFormRegister, FieldErrors } from 'react-hook-form';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { CreateListingFormData } from '@/lib/validations/listings';
|
||||
import { StepBasicInfo, StepLocation, StepDetails, StepPricing } from '../listing-form-steps';
|
||||
|
||||
// Minimal register mock that returns required react-hook-form props
|
||||
function mockRegister(): UseFormRegister<CreateListingFormData> {
|
||||
return vi.fn().mockImplementation((name: string) => ({
|
||||
name,
|
||||
onChange: vi.fn(),
|
||||
onBlur: vi.fn(),
|
||||
ref: vi.fn(),
|
||||
})) as unknown as UseFormRegister<CreateListingFormData>;
|
||||
}
|
||||
|
||||
const noErrors: FieldErrors<CreateListingFormData> = {};
|
||||
|
||||
// ─── StepBasicInfo ──────────────────────────────────────
|
||||
|
||||
describe('StepBasicInfo', () => {
|
||||
it('renders the step heading', () => {
|
||||
render(<StepBasicInfo register={mockRegister()} errors={noErrors} />);
|
||||
expect(screen.getByText('Thông tin cơ bản')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders transaction type select', () => {
|
||||
render(<StepBasicInfo register={mockRegister()} errors={noErrors} />);
|
||||
expect(screen.getByLabelText('Loại giao dịch *')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders property type select', () => {
|
||||
render(<StepBasicInfo register={mockRegister()} errors={noErrors} />);
|
||||
expect(screen.getByLabelText('Loại bất động sản *')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders title input', () => {
|
||||
render(<StepBasicInfo register={mockRegister()} errors={noErrors} />);
|
||||
expect(screen.getByLabelText('Tiêu đề tin đăng *')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders description textarea', () => {
|
||||
render(<StepBasicInfo register={mockRegister()} errors={noErrors} />);
|
||||
expect(screen.getByLabelText('Mô tả chi tiết *')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders transaction type options', () => {
|
||||
render(<StepBasicInfo register={mockRegister()} errors={noErrors} />);
|
||||
expect(screen.getByText('Bán')).toBeInTheDocument();
|
||||
expect(screen.getByText('Cho thuê')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders property type options', () => {
|
||||
render(<StepBasicInfo register={mockRegister()} errors={noErrors} />);
|
||||
expect(screen.getByText('Căn hộ')).toBeInTheDocument();
|
||||
expect(screen.getByText('Nhà riêng')).toBeInTheDocument();
|
||||
expect(screen.getByText('Biệt thự')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows error message for transactionType', () => {
|
||||
const errors: FieldErrors<CreateListingFormData> = {
|
||||
transactionType: { type: 'required', message: 'Vui lòng chọn loại giao dịch' },
|
||||
};
|
||||
render(<StepBasicInfo register={mockRegister()} errors={errors} />);
|
||||
expect(screen.getByText('Vui lòng chọn loại giao dịch')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows error message for title', () => {
|
||||
const errors: FieldErrors<CreateListingFormData> = {
|
||||
title: { type: 'min', message: 'Tiêu đề tối thiểu 5 ký tự' },
|
||||
};
|
||||
render(<StepBasicInfo register={mockRegister()} errors={errors} />);
|
||||
expect(screen.getByText('Tiêu đề tối thiểu 5 ký tự')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── StepLocation ───────────────────────────────────────
|
||||
|
||||
describe('StepLocation', () => {
|
||||
it('renders the step heading', () => {
|
||||
render(<StepLocation register={mockRegister()} errors={noErrors} />);
|
||||
expect(screen.getByText('Vị trí')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders address input', () => {
|
||||
render(<StepLocation register={mockRegister()} errors={noErrors} />);
|
||||
expect(screen.getByLabelText('Địa chỉ *')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders ward input', () => {
|
||||
render(<StepLocation register={mockRegister()} errors={noErrors} />);
|
||||
expect(screen.getByLabelText('Phường/Xã *')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders district input', () => {
|
||||
render(<StepLocation register={mockRegister()} errors={noErrors} />);
|
||||
expect(screen.getByLabelText('Quận/Huyện *')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders city input', () => {
|
||||
render(<StepLocation register={mockRegister()} errors={noErrors} />);
|
||||
expect(screen.getByLabelText('Tỉnh/Thành phố *')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders latitude and longitude inputs', () => {
|
||||
render(<StepLocation register={mockRegister()} errors={noErrors} />);
|
||||
expect(screen.getByLabelText('Vĩ độ')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Kinh độ')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders map placeholder text', () => {
|
||||
render(<StepLocation register={mockRegister()} errors={noErrors} />);
|
||||
expect(screen.getByText(/Bản đồ chọn vị trí sẽ được tích hợp/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows error for address', () => {
|
||||
const errors: FieldErrors<CreateListingFormData> = {
|
||||
address: { type: 'required', message: 'Vui lòng nhập địa chỉ' },
|
||||
};
|
||||
render(<StepLocation register={mockRegister()} errors={errors} />);
|
||||
expect(screen.getByText('Vui lòng nhập địa chỉ')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── StepDetails ────────────────────────────────────────
|
||||
|
||||
describe('StepDetails', () => {
|
||||
it('renders the step heading', () => {
|
||||
render(<StepDetails register={mockRegister()} errors={noErrors} />);
|
||||
expect(screen.getByText('Thông số chi tiết')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders area input', () => {
|
||||
render(<StepDetails register={mockRegister()} errors={noErrors} />);
|
||||
expect(screen.getByLabelText('Diện tích (m²) *')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders bedroom and bathroom inputs', () => {
|
||||
render(<StepDetails register={mockRegister()} errors={noErrors} />);
|
||||
expect(screen.getByLabelText('Phòng ngủ')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Phòng tắm')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders direction select', () => {
|
||||
render(<StepDetails register={mockRegister()} errors={noErrors} />);
|
||||
expect(screen.getByLabelText('Hướng nhà')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders direction options', () => {
|
||||
render(<StepDetails register={mockRegister()} errors={noErrors} />);
|
||||
expect(screen.getByText('Bắc')).toBeInTheDocument();
|
||||
expect(screen.getByText('Nam')).toBeInTheDocument();
|
||||
expect(screen.getByText('Đông')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders year built input', () => {
|
||||
render(<StepDetails register={mockRegister()} errors={noErrors} />);
|
||||
expect(screen.getByLabelText('Năm xây dựng')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders legal status and project name inputs', () => {
|
||||
render(<StepDetails register={mockRegister()} errors={noErrors} />);
|
||||
expect(screen.getByLabelText('Pháp lý')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Tên dự án')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders amenities input', () => {
|
||||
render(<StepDetails register={mockRegister()} errors={noErrors} />);
|
||||
expect(screen.getByLabelText(/Tiện ích/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── StepPricing ────────────────────────────────────────
|
||||
|
||||
describe('StepPricing', () => {
|
||||
it('renders the step heading', () => {
|
||||
render(<StepPricing register={mockRegister()} errors={noErrors} />);
|
||||
expect(screen.getByText('Giá & Hoa hồng')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders price input', () => {
|
||||
render(<StepPricing register={mockRegister()} errors={noErrors} />);
|
||||
expect(screen.getByLabelText('Giá bán (VNĐ) *')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders rent price and commission inputs', () => {
|
||||
render(<StepPricing register={mockRegister()} errors={noErrors} />);
|
||||
expect(screen.getByLabelText('Giá thuê/tháng (VNĐ)')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Hoa hồng (%)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders price format hint', () => {
|
||||
render(<StepPricing register={mockRegister()} errors={noErrors} />);
|
||||
expect(screen.getByText(/Nhập số không có dấu chấm/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows error for priceVND', () => {
|
||||
const errors: FieldErrors<CreateListingFormData> = {
|
||||
priceVND: { type: 'required', message: 'Giá bán là bắt buộc' },
|
||||
};
|
||||
render(<StepPricing register={mockRegister()} errors={errors} />);
|
||||
expect(screen.getByText('Giá bán là bắt buộc')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ListingStatusBadge } from '../listing-status-badge';
|
||||
|
||||
describe('ListingStatusBadge', () => {
|
||||
it('renders ACTIVE status with correct label', () => {
|
||||
render(<ListingStatusBadge status="ACTIVE" />);
|
||||
expect(screen.getByText('Đang bán')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders DRAFT status with correct label', () => {
|
||||
render(<ListingStatusBadge status="DRAFT" />);
|
||||
expect(screen.getByText('Nháp')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders PENDING_REVIEW status with correct label', () => {
|
||||
render(<ListingStatusBadge status="PENDING_REVIEW" />);
|
||||
expect(screen.getByText('Chờ duyệt')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders SOLD status with correct label', () => {
|
||||
render(<ListingStatusBadge status="SOLD" />);
|
||||
expect(screen.getByText('Đã bán')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders RENTED status with correct label', () => {
|
||||
render(<ListingStatusBadge status="RENTED" />);
|
||||
expect(screen.getByText('Đã cho thuê')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders EXPIRED status with correct label', () => {
|
||||
render(<ListingStatusBadge status="EXPIRED" />);
|
||||
expect(screen.getByText('Hết hạn')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders REJECTED status with correct label', () => {
|
||||
render(<ListingStatusBadge status="REJECTED" />);
|
||||
expect(screen.getByText('Bị từ chối')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders RESERVED status with correct label', () => {
|
||||
render(<ListingStatusBadge status="RESERVED" />);
|
||||
expect(screen.getByText('Đã đặt cọc')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('falls back to raw status value for unknown status', () => {
|
||||
// @ts-expect-error testing unknown status
|
||||
render(<ListingStatusBadge status="UNKNOWN_STATUS" />);
|
||||
expect(screen.getByText('UNKNOWN_STATUS')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user