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:
137
apps/web/components/search/__tests__/filter-bar.spec.tsx
Normal file
137
apps/web/components/search/__tests__/filter-bar.spec.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { type SearchFilters, FilterBar } from '../filter-bar';
|
||||
|
||||
// Mock next-intl
|
||||
vi.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
filters: 'Bộ lọc',
|
||||
allTransactions: 'Tất cả giao dịch',
|
||||
allPropertyTypes: 'Tất cả loại BĐS',
|
||||
allAreas: 'Tất cả khu vực',
|
||||
allPrices: 'Tất cả mức giá',
|
||||
bedrooms: 'Phòng ngủ',
|
||||
searchButton: 'Tìm kiếm',
|
||||
areaLabel: 'Diện tích',
|
||||
areaFrom: 'Từ',
|
||||
areaTo: 'Đến',
|
||||
district: 'Quận/Huyện',
|
||||
'bedroomsCount': '1+ PN',
|
||||
'priceRanges.under1b': 'Dưới 1 tỷ',
|
||||
'priceRanges.1to3b': '1-3 tỷ',
|
||||
'priceRanges.3to5b': '3-5 tỷ',
|
||||
'priceRanges.5to10b': '5-10 tỷ',
|
||||
'priceRanges.10to20b': '10-20 tỷ',
|
||||
'priceRanges.over20b': 'Trên 20 tỷ',
|
||||
};
|
||||
return translations[key] ?? key;
|
||||
},
|
||||
}));
|
||||
|
||||
const defaultFilters: SearchFilters = {
|
||||
transactionType: '',
|
||||
propertyType: '',
|
||||
city: '',
|
||||
district: '',
|
||||
minPrice: '',
|
||||
maxPrice: '',
|
||||
minArea: '',
|
||||
maxArea: '',
|
||||
bedrooms: '',
|
||||
sort: '',
|
||||
};
|
||||
|
||||
describe('FilterBar', () => {
|
||||
it('renders transaction type select', () => {
|
||||
render(<FilterBar filters={defaultFilters} onChange={vi.fn()} onSearch={vi.fn()} />);
|
||||
expect(screen.getByLabelText('Tất cả giao dịch')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders property type select', () => {
|
||||
render(<FilterBar filters={defaultFilters} onChange={vi.fn()} onSearch={vi.fn()} />);
|
||||
expect(screen.getByLabelText('Tất cả loại BĐS')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders city select', () => {
|
||||
render(<FilterBar filters={defaultFilters} onChange={vi.fn()} onSearch={vi.fn()} />);
|
||||
expect(screen.getByLabelText('Tất cả khu vực')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders transaction type options', () => {
|
||||
render(<FilterBar filters={defaultFilters} onChange={vi.fn()} onSearch={vi.fn()} />);
|
||||
expect(screen.getByText('Bán')).toBeInTheDocument();
|
||||
expect(screen.getByText('Cho thuê')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders city options', () => {
|
||||
render(<FilterBar filters={defaultFilters} onChange={vi.fn()} onSearch={vi.fn()} />);
|
||||
expect(screen.getByText('Hồ Chí Minh')).toBeInTheDocument();
|
||||
expect(screen.getByText('Hà Nội')).toBeInTheDocument();
|
||||
expect(screen.getByText('Đà Nẵng')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders bedrooms select', () => {
|
||||
render(<FilterBar filters={defaultFilters} onChange={vi.fn()} onSearch={vi.fn()} />);
|
||||
expect(screen.getByLabelText('Phòng ngủ')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('has search role', () => {
|
||||
render(<FilterBar filters={defaultFilters} onChange={vi.fn()} onSearch={vi.fn()} />);
|
||||
expect(screen.getByRole('search')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onChange when transaction type changes', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
render(<FilterBar filters={defaultFilters} onChange={onChange} onSearch={vi.fn()} />);
|
||||
|
||||
await user.selectOptions(screen.getByLabelText('Tất cả giao dịch'), 'SALE');
|
||||
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ transactionType: 'SALE' }));
|
||||
});
|
||||
|
||||
it('calls onChange when city changes', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
render(<FilterBar filters={defaultFilters} onChange={onChange} onSearch={vi.fn()} />);
|
||||
|
||||
await user.selectOptions(screen.getByLabelText('Tất cả khu vực'), 'Hồ Chí Minh');
|
||||
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ city: 'Hồ Chí Minh' }));
|
||||
});
|
||||
|
||||
// Sidebar layout
|
||||
it('renders search button in sidebar layout', () => {
|
||||
render(<FilterBar filters={defaultFilters} onChange={vi.fn()} onSearch={vi.fn()} layout="sidebar" />);
|
||||
expect(screen.getByText('Tìm kiếm')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders heading in sidebar layout', () => {
|
||||
render(<FilterBar filters={defaultFilters} onChange={vi.fn()} onSearch={vi.fn()} layout="sidebar" />);
|
||||
expect(screen.getByText('Bộ lọc')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders area inputs in sidebar layout', () => {
|
||||
render(<FilterBar filters={defaultFilters} onChange={vi.fn()} onSearch={vi.fn()} layout="sidebar" />);
|
||||
expect(screen.getByLabelText(/Diện tích Từ/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders district input in sidebar layout', () => {
|
||||
render(<FilterBar filters={defaultFilters} onChange={vi.fn()} onSearch={vi.fn()} layout="sidebar" />);
|
||||
expect(screen.getByLabelText('Quận/Huyện')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onSearch when search button clicked in sidebar', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSearch = vi.fn();
|
||||
render(<FilterBar filters={defaultFilters} onChange={vi.fn()} onSearch={onSearch} layout="sidebar" />);
|
||||
|
||||
await user.click(screen.getByText('Tìm kiếm'));
|
||||
expect(onSearch).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not render search button in horizontal layout', () => {
|
||||
render(<FilterBar filters={defaultFilters} onChange={vi.fn()} onSearch={vi.fn()} layout="horizontal" />);
|
||||
expect(screen.queryByText('Tìm kiếm')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
183
apps/web/components/search/__tests__/search-results.spec.tsx
Normal file
183
apps/web/components/search/__tests__/search-results.spec.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { ListingDetail, PaginatedResult } from '@/lib/listings-api';
|
||||
import { SearchResults } from '../search-results';
|
||||
|
||||
// Mock PropertyCard
|
||||
vi.mock('../property-card', () => ({
|
||||
PropertyCard: ({ listing }: { listing: ListingDetail }) => (
|
||||
<div data-testid={`property-card-${listing.id}`}>{listing.property.title}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
function makeListing(id: string): ListingDetail {
|
||||
return {
|
||||
id,
|
||||
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-${id}`,
|
||||
propertyType: 'APARTMENT',
|
||||
title: `Listing ${id}`,
|
||||
description: 'Test listing',
|
||||
address: '123 Test St',
|
||||
ward: 'Ward',
|
||||
district: 'District',
|
||||
city: 'HCMC',
|
||||
areaM2: 75,
|
||||
bedrooms: 2,
|
||||
bathrooms: 2,
|
||||
floors: null,
|
||||
direction: null,
|
||||
yearBuilt: null,
|
||||
legalStatus: null,
|
||||
amenities: [],
|
||||
projectName: null,
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
media: [],
|
||||
},
|
||||
seller: { id: 's1', fullName: 'Seller', phone: '0901234567' },
|
||||
agent: null,
|
||||
};
|
||||
}
|
||||
|
||||
function makeResult(count: number, page = 1, totalPages = 1): PaginatedResult<ListingDetail> {
|
||||
return {
|
||||
data: Array.from({ length: count }, (_, i) => makeListing(`${i + 1}`)),
|
||||
total: count,
|
||||
page,
|
||||
limit: 10,
|
||||
totalPages,
|
||||
};
|
||||
}
|
||||
|
||||
const defaultProps = {
|
||||
page: 1,
|
||||
sort: '',
|
||||
onPageChange: vi.fn(),
|
||||
onSortChange: vi.fn(),
|
||||
};
|
||||
|
||||
describe('SearchResults', () => {
|
||||
it('renders loading spinner when loading', () => {
|
||||
const { container } = render(
|
||||
<SearchResults result={null} loading={true} {...defaultProps} />,
|
||||
);
|
||||
expect(container.querySelector('.animate-spin')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders error state', () => {
|
||||
render(
|
||||
<SearchResults result={null} loading={false} error={true} {...defaultProps} />,
|
||||
);
|
||||
expect(screen.getByText('Không thể tải kết quả tìm kiếm')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders retry button in error state', () => {
|
||||
const onRetry = vi.fn();
|
||||
render(
|
||||
<SearchResults result={null} loading={false} error={true} onRetry={onRetry} {...defaultProps} />,
|
||||
);
|
||||
expect(screen.getByText('Thử lại')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onRetry when retry button clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRetry = vi.fn();
|
||||
render(
|
||||
<SearchResults result={null} loading={false} error={true} onRetry={onRetry} {...defaultProps} />,
|
||||
);
|
||||
|
||||
await user.click(screen.getByText('Thử lại'));
|
||||
expect(onRetry).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders empty state when no results', () => {
|
||||
render(
|
||||
<SearchResults result={makeResult(0)} loading={false} {...defaultProps} />,
|
||||
);
|
||||
expect(screen.getByText('Không tìm thấy kết quả')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders empty state with null result', () => {
|
||||
render(
|
||||
<SearchResults result={null} loading={false} {...defaultProps} />,
|
||||
);
|
||||
expect(screen.getByText('Không tìm thấy kết quả')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders property cards for results', () => {
|
||||
render(
|
||||
<SearchResults result={makeResult(3)} loading={false} {...defaultProps} />,
|
||||
);
|
||||
expect(screen.getByTestId('property-card-1')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('property-card-2')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('property-card-3')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders total results count', () => {
|
||||
render(
|
||||
<SearchResults result={makeResult(3)} loading={false} {...defaultProps} />,
|
||||
);
|
||||
expect(screen.getByText('3 kết quả')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders sort select', () => {
|
||||
render(
|
||||
<SearchResults result={makeResult(3)} loading={false} {...defaultProps} />,
|
||||
);
|
||||
expect(screen.getByText('Mới nhất')).toBeInTheDocument();
|
||||
expect(screen.getByText('Giá: Thấp đến cao')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders pagination buttons for multi-page results', () => {
|
||||
render(
|
||||
<SearchResults result={makeResult(3, 1, 3)} loading={false} {...defaultProps} />,
|
||||
);
|
||||
expect(screen.getByText('Trước')).toBeInTheDocument();
|
||||
expect(screen.getByText('Tiếp')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables Trước button on first page', () => {
|
||||
render(
|
||||
<SearchResults result={makeResult(3, 1, 3)} loading={false} page={1} sort="" onPageChange={vi.fn()} onSortChange={vi.fn()} />,
|
||||
);
|
||||
expect(screen.getByText('Trước')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('disables Tiếp button on last page', () => {
|
||||
render(
|
||||
<SearchResults result={makeResult(3, 3, 3)} loading={false} page={3} sort="" onPageChange={vi.fn()} onSortChange={vi.fn()} />,
|
||||
);
|
||||
expect(screen.getByText('Tiếp')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('calls onPageChange when Tiếp clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onPageChange = vi.fn();
|
||||
render(
|
||||
<SearchResults result={makeResult(3, 1, 3)} loading={false} page={1} sort="" onPageChange={onPageChange} onSortChange={vi.fn()} />,
|
||||
);
|
||||
|
||||
await user.click(screen.getByText('Tiếp'));
|
||||
expect(onPageChange).toHaveBeenCalledWith(2);
|
||||
});
|
||||
|
||||
it('does not render pagination for single page', () => {
|
||||
render(
|
||||
<SearchResults result={makeResult(3, 1, 1)} loading={false} {...defaultProps} />,
|
||||
);
|
||||
expect(screen.queryByText('Trước')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user