feat(cache): implement Redis caching for search & analytics hot paths

- Add TTL-specific cache durations: district stats (5min), market report (15min), heatmap (5min)
- Add Redis caching to GeoSearch handler with 60s TTL
- Add cache invalidation on listing.approved, listing.updated, listing.deactivated, listing.sold events
- Invalidate search, geo_search, and all analytics cache prefixes on listing state changes
- Update tests for new CacheService dependency in event handler and geo-search handler

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-08 22:51:16 +07:00
parent 03231271ca
commit ccb82fddf8
18 changed files with 1885 additions and 19 deletions

View File

@@ -0,0 +1,110 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const mockPush = vi.fn();
vi.mock('next/navigation', () => ({
useRouter: () => ({ push: mockPush }),
}));
vi.mock('@/lib/listings-api', () => ({
listingsApi: {
create: vi.fn(),
uploadMedia: vi.fn(),
},
}));
vi.mock('@/components/listings/image-upload', () => ({
ImageUpload: ({ onChange }: { onChange: (imgs: unknown[]) => void }) => (
<div data-testid="image-upload">
<button type="button" onClick={() => onChange([])}>Upload Mock</button>
</div>
),
}));
import { listingsApi } from '@/lib/listings-api';
import CreateListingPage from '../new/page';
const mockedListingsApi = vi.mocked(listingsApi);
describe('CreateListingPage', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renders the page title and step indicators', () => {
render(<CreateListingPage />);
expect(screen.getByText('Đăng tin mới')).toBeInTheDocument();
expect(screen.getByText('Thông tin')).toBeInTheDocument();
expect(screen.getByText('Vị trí')).toBeInTheDocument();
expect(screen.getByText('Chi tiết')).toBeInTheDocument();
expect(screen.getByText('Giá cả')).toBeInTheDocument();
expect(screen.getByText('Hình ảnh')).toBeInTheDocument();
});
it('renders step 1 (basic info) initially', () => {
render(<CreateListingPage />);
expect(screen.getByText('Thông tin cơ bản')).toBeInTheDocument();
expect(screen.getByLabelText(/loại giao dịch/i)).toBeInTheDocument();
expect(screen.getByLabelText(/loại bất động sản/i)).toBeInTheDocument();
expect(screen.getByLabelText(/tiêu đề/i)).toBeInTheDocument();
expect(screen.getByLabelText(/mô tả/i)).toBeInTheDocument();
});
it('has back button disabled on first step', () => {
render(<CreateListingPage />);
expect(screen.getByRole('button', { name: /quay lại/i })).toBeDisabled();
});
it('navigates to step 2 when basic info is filled and next is clicked', async () => {
render(<CreateListingPage />);
// Fill step 1
await userEvent.selectOptions(screen.getByLabelText(/loại giao dịch/i), 'SALE');
await userEvent.selectOptions(screen.getByLabelText(/loại bất động sản/i), 'APARTMENT');
await userEvent.type(screen.getByLabelText(/tiêu đề/i), 'Bán căn hộ 2PN tại Quận 7');
await userEvent.type(screen.getByLabelText(/mô tả/i), 'Căn hộ view sông tuyệt đẹp, nội thất cao cấp');
await userEvent.click(screen.getByRole('button', { name: /tiếp theo/i }));
await waitFor(() => {
expect(screen.getByLabelText(/địa chỉ/i)).toBeInTheDocument();
});
});
it('shows validation errors when required fields are empty on step 1', async () => {
render(<CreateListingPage />);
await userEvent.click(screen.getByRole('button', { name: /tiếp theo/i }));
// Step should not advance - still showing basic info
await waitFor(() => {
expect(screen.getByText('Thông tin cơ bản')).toBeInTheDocument();
});
});
it('navigates back to previous step', async () => {
render(<CreateListingPage />);
// Fill step 1 and go to step 2
await userEvent.selectOptions(screen.getByLabelText(/loại giao dịch/i), 'SALE');
await userEvent.selectOptions(screen.getByLabelText(/loại bất động sản/i), 'APARTMENT');
await userEvent.type(screen.getByLabelText(/tiêu đề/i), 'Test listing title here');
await userEvent.type(screen.getByLabelText(/mô tả/i), 'A detailed description of the property for sale');
await userEvent.click(screen.getByRole('button', { name: /tiếp theo/i }));
await waitFor(() => {
expect(screen.getByLabelText(/địa chỉ/i)).toBeInTheDocument();
});
// Go back
await userEvent.click(screen.getByRole('button', { name: /quay lại/i }));
await waitFor(() => {
expect(screen.getByText('Thông tin cơ bản')).toBeInTheDocument();
});
});
});