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,140 @@
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();
const mockReplace = vi.fn();
const mockSearchParams = new URLSearchParams();
vi.mock('next/navigation', () => ({
useRouter: () => ({ push: mockPush, replace: mockReplace }),
useSearchParams: () => mockSearchParams,
}));
vi.mock('next/link', () => ({
default: ({ children, href, ...props }: { children: React.ReactNode; href: string; [key: string]: unknown }) => (
<a href={href} {...props}>{children}</a>
),
}));
vi.mock('next/image', () => ({
default: (props: Record<string, unknown>) => <img {...props} />,
}));
// Mock dynamic import for map component
vi.mock('next/dynamic', () => ({
default: () => {
const MockMap = () => <div data-testid="map-placeholder">Map</div>;
MockMap.displayName = 'MockMap';
return MockMap;
},
}));
const mockListings = {
data: [
{
id: '1',
status: 'ACTIVE',
transactionType: 'SALE',
priceVND: '5000000000',
pricePerM2: null,
rentPriceMonthly: null,
commissionPct: null,
viewCount: 10,
saveCount: 2,
inquiryCount: 1,
publishedAt: '2024-01-01',
createdAt: '2024-01-01',
property: {
id: 'p1',
propertyType: 'APARTMENT',
title: 'Căn hộ Quận 7',
description: 'Căn hộ view sông',
address: '123 Nguyễn Hữu Thọ',
ward: 'Phường Tân Hưng',
district: 'Quận 7',
city: 'Hồ Chí Minh',
areaM2: 75,
bedrooms: 2,
bathrooms: 2,
floors: null,
direction: null,
yearBuilt: null,
legalStatus: null,
amenities: null,
projectName: null,
media: [],
},
seller: { id: 's1', fullName: 'Nguyen Van A', phone: '0912345678' },
agent: null,
},
],
total: 1,
page: 1,
limit: 12,
totalPages: 1,
};
vi.mock('@/lib/listings-api', () => ({
listingsApi: {
search: vi.fn(),
},
}));
import { listingsApi } from '@/lib/listings-api';
import SearchPage from '../page';
const mockedListingsApi = vi.mocked(listingsApi);
describe('SearchPage', () => {
beforeEach(() => {
vi.clearAllMocks();
mockedListingsApi.search.mockResolvedValue(mockListings as never);
});
it('renders the search page title', async () => {
render(<SearchPage />);
await waitFor(() => {
expect(screen.getByText('Tìm kiếm bất động sản')).toBeInTheDocument();
});
});
it('renders view mode toggle buttons', async () => {
render(<SearchPage />);
await waitFor(() => {
expect(screen.getByRole('button', { name: /danh sách/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /bản đồ/i })).toBeInTheDocument();
});
});
it('calls listings API on mount', async () => {
render(<SearchPage />);
await waitFor(() => {
expect(mockedListingsApi.search).toHaveBeenCalled();
});
});
it('displays listing results after loading', async () => {
render(<SearchPage />);
await waitFor(() => {
expect(screen.getByText(/căn hộ quận 7/i)).toBeInTheDocument();
});
});
it('switches to map view when map button is clicked', async () => {
render(<SearchPage />);
await waitFor(() => {
expect(screen.getByRole('button', { name: /bản đồ/i })).toBeInTheDocument();
});
await userEvent.click(screen.getByRole('button', { name: /bản đồ/i }));
await waitFor(() => {
expect(screen.getByTestId('map-placeholder')).toBeInTheDocument();
});
});
});