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:
Ho Ngoc Hai
2026-04-11 23:43:20 +07:00
parent 9e2bf9a4b5
commit 1fbe2f4e73
131 changed files with 11436 additions and 2595 deletions

View 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();
});
});

View 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();
});
});

View 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');
});
});

View File

@@ -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();
});
});

View File

@@ -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();
});
});

View File

@@ -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();
});
});