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,35 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { Badge } from '../badge';
describe('Badge', () => {
it('renders with text content', () => {
render(<Badge>Active</Badge>);
expect(screen.getByText('Active')).toBeInTheDocument();
});
it('applies default variant styles', () => {
render(<Badge data-testid="badge">Default</Badge>);
expect(screen.getByTestId('badge')).toHaveClass('bg-primary');
});
it('applies destructive variant', () => {
render(<Badge data-testid="badge" variant="destructive">Error</Badge>);
expect(screen.getByTestId('badge')).toHaveClass('bg-destructive');
});
it('applies success variant', () => {
render(<Badge data-testid="badge" variant="success">OK</Badge>);
expect(screen.getByTestId('badge')).toHaveClass('bg-green-100');
});
it('applies warning variant', () => {
render(<Badge data-testid="badge" variant="warning">Warn</Badge>);
expect(screen.getByTestId('badge')).toHaveClass('bg-yellow-100');
});
it('applies custom className', () => {
render(<Badge data-testid="badge" className="extra">Custom</Badge>);
expect(screen.getByTestId('badge')).toHaveClass('extra');
});
});

View File

@@ -0,0 +1,60 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import { Button } from '../button';
describe('Button', () => {
it('renders with children text', () => {
render(<Button>Click me</Button>);
expect(screen.getByRole('button', { name: 'Click me' })).toBeInTheDocument();
});
it('handles click events', async () => {
const onClick = vi.fn();
render(<Button onClick={onClick}>Click</Button>);
await userEvent.click(screen.getByRole('button'));
expect(onClick).toHaveBeenCalledOnce();
});
it('is disabled when disabled prop is set', () => {
render(<Button disabled>Disabled</Button>);
expect(screen.getByRole('button')).toBeDisabled();
});
it('does not fire click when disabled', async () => {
const onClick = vi.fn();
render(<Button disabled onClick={onClick}>Disabled</Button>);
await userEvent.click(screen.getByRole('button'));
expect(onClick).not.toHaveBeenCalled();
});
it('applies variant classes for destructive', () => {
render(<Button variant="destructive">Delete</Button>);
expect(screen.getByRole('button')).toHaveClass('bg-destructive');
});
it('applies variant classes for outline', () => {
render(<Button variant="outline">Outline</Button>);
expect(screen.getByRole('button')).toHaveClass('border');
});
it('applies size classes for sm', () => {
render(<Button size="sm">Small</Button>);
expect(screen.getByRole('button')).toHaveClass('h-9');
});
it('applies size classes for lg', () => {
render(<Button size="lg">Large</Button>);
expect(screen.getByRole('button')).toHaveClass('h-11');
});
it('applies custom className', () => {
render(<Button className="custom-class">Custom</Button>);
expect(screen.getByRole('button')).toHaveClass('custom-class');
});
it('renders as submit button when type is set', () => {
render(<Button type="submit">Submit</Button>);
expect(screen.getByRole('button')).toHaveAttribute('type', 'submit');
});
});

View File

@@ -0,0 +1,40 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '../card';
describe('Card', () => {
it('renders card with all sub-components', () => {
render(
<Card data-testid="card">
<CardHeader>
<CardTitle>Title</CardTitle>
<CardDescription>Description</CardDescription>
</CardHeader>
<CardContent>Content</CardContent>
<CardFooter>Footer</CardFooter>
</Card>,
);
expect(screen.getByTestId('card')).toBeInTheDocument();
expect(screen.getByText('Title')).toBeInTheDocument();
expect(screen.getByText('Description')).toBeInTheDocument();
expect(screen.getByText('Content')).toBeInTheDocument();
expect(screen.getByText('Footer')).toBeInTheDocument();
});
it('applies custom className to Card', () => {
render(<Card data-testid="card" className="custom">Content</Card>);
expect(screen.getByTestId('card')).toHaveClass('custom');
expect(screen.getByTestId('card')).toHaveClass('rounded-lg');
});
it('renders CardTitle as h3', () => {
render(<CardTitle>My Title</CardTitle>);
expect(screen.getByRole('heading', { level: 3 })).toHaveTextContent('My Title');
});
it('renders CardDescription as paragraph', () => {
render(<CardDescription>My Description</CardDescription>);
expect(screen.getByText('My Description').tagName).toBe('P');
});
});

View File

@@ -0,0 +1,71 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '../dialog';
describe('Dialog', () => {
it('renders nothing when open is false', () => {
render(
<Dialog open={false} onOpenChange={() => {}}>
<DialogContent>
<DialogTitle>Hidden</DialogTitle>
</DialogContent>
</Dialog>,
);
expect(screen.queryByText('Hidden')).not.toBeInTheDocument();
});
it('renders content when open is true', () => {
render(
<Dialog open={true} onOpenChange={() => {}}>
<DialogContent>
<DialogHeader>
<DialogTitle>Test Dialog</DialogTitle>
<DialogDescription>Dialog description</DialogDescription>
</DialogHeader>
<p>Body content</p>
<DialogFooter>
<button>OK</button>
</DialogFooter>
</DialogContent>
</Dialog>,
);
expect(screen.getByText('Test Dialog')).toBeInTheDocument();
expect(screen.getByText('Dialog description')).toBeInTheDocument();
expect(screen.getByText('Body content')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'OK' })).toBeInTheDocument();
});
it('calls onOpenChange when backdrop is clicked', async () => {
const onOpenChange = vi.fn();
render(
<Dialog open={true} onOpenChange={onOpenChange}>
<DialogContent>
<DialogTitle>Closeable</DialogTitle>
</DialogContent>
</Dialog>,
);
// Click the backdrop (the overlay div)
const backdrop = document.querySelector('.bg-black\\/80');
if (backdrop) {
await userEvent.click(backdrop);
}
expect(onOpenChange).toHaveBeenCalledWith(false);
});
it('does not close when clicking inside content', async () => {
const onOpenChange = vi.fn();
render(
<Dialog open={true} onOpenChange={onOpenChange}>
<DialogContent>
<DialogTitle>Stay Open</DialogTitle>
</DialogContent>
</Dialog>,
);
await userEvent.click(screen.getByText('Stay Open'));
expect(onOpenChange).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,40 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import { Input } from '../input';
describe('Input', () => {
it('renders an input element', () => {
render(<Input placeholder="Enter text" />);
expect(screen.getByPlaceholderText('Enter text')).toBeInTheDocument();
});
it('accepts and displays typed value', async () => {
render(<Input placeholder="Type here" />);
const input = screen.getByPlaceholderText('Type here');
await userEvent.type(input, 'Hello');
expect(input).toHaveValue('Hello');
});
it('applies type attribute', () => {
render(<Input type="email" placeholder="Email" />);
expect(screen.getByPlaceholderText('Email')).toHaveAttribute('type', 'email');
});
it('is disabled when disabled prop is set', () => {
render(<Input disabled placeholder="Disabled" />);
expect(screen.getByPlaceholderText('Disabled')).toBeDisabled();
});
it('calls onChange handler', async () => {
const onChange = vi.fn();
render(<Input onChange={onChange} placeholder="Input" />);
await userEvent.type(screen.getByPlaceholderText('Input'), 'a');
expect(onChange).toHaveBeenCalled();
});
it('applies custom className', () => {
render(<Input className="my-class" placeholder="Custom" />);
expect(screen.getByPlaceholderText('Custom')).toHaveClass('my-class');
});
});

View File

@@ -0,0 +1,25 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { Label } from '../label';
describe('Label', () => {
it('renders label text', () => {
render(<Label>Số điện thoại</Label>);
expect(screen.getByText('Số điện thoại')).toBeInTheDocument();
});
it('associates with input via htmlFor', () => {
render(
<>
<Label htmlFor="phone">Phone</Label>
<input id="phone" />
</>,
);
expect(screen.getByLabelText('Phone')).toBeInTheDocument();
});
it('applies custom className', () => {
render(<Label data-testid="label" className="custom">Label</Label>);
expect(screen.getByTestId('label')).toHaveClass('custom');
});
});

View File

@@ -0,0 +1,40 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import { Select } from '../select';
describe('Select', () => {
it('renders with options', () => {
render(
<Select aria-label="Property type">
<option value="">Chọn loại</option>
<option value="APARTMENT">Căn hộ</option>
<option value="HOUSE">Nhà phố</option>
</Select>,
);
expect(screen.getByRole('combobox', { name: 'Property type' })).toBeInTheDocument();
expect(screen.getAllByRole('option')).toHaveLength(3);
});
it('handles value change', async () => {
const onChange = vi.fn();
render(
<Select aria-label="Type" onChange={onChange}>
<option value="">Chọn</option>
<option value="SALE">Bán</option>
<option value="RENT">Cho thuê</option>
</Select>,
);
await userEvent.selectOptions(screen.getByRole('combobox'), 'SALE');
expect(onChange).toHaveBeenCalled();
});
it('is disabled when disabled prop is set', () => {
render(
<Select disabled aria-label="Disabled select">
<option>Option</option>
</Select>,
);
expect(screen.getByRole('combobox')).toBeDisabled();
});
});

View File

@@ -0,0 +1,59 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '../table';
describe('Table', () => {
it('renders a complete table structure', () => {
render(
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Price</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell>Apartment</TableCell>
<TableCell>1,000,000 VND</TableCell>
</TableRow>
</TableBody>
</Table>,
);
expect(screen.getByRole('table')).toBeInTheDocument();
expect(screen.getByText('Name')).toBeInTheDocument();
expect(screen.getByText('Price')).toBeInTheDocument();
expect(screen.getByText('Apartment')).toBeInTheDocument();
expect(screen.getByText('1,000,000 VND')).toBeInTheDocument();
});
it('renders multiple rows', () => {
render(
<Table>
<TableBody>
<TableRow><TableCell>Row 1</TableCell></TableRow>
<TableRow><TableCell>Row 2</TableCell></TableRow>
<TableRow><TableCell>Row 3</TableCell></TableRow>
</TableBody>
</Table>,
);
expect(screen.getAllByRole('row')).toHaveLength(3);
});
it('applies custom className to table elements', () => {
render(
<Table>
<TableBody>
<TableRow data-testid="row" className="highlight">
<TableCell data-testid="cell" className="bold">Data</TableCell>
</TableRow>
</TableBody>
</Table>,
);
expect(screen.getByTestId('row')).toHaveClass('highlight');
expect(screen.getByTestId('cell')).toHaveClass('bold');
});
});

View File

@@ -0,0 +1,28 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it } from 'vitest';
import { Textarea } from '../textarea';
describe('Textarea', () => {
it('renders a textarea element', () => {
render(<Textarea placeholder="Mô tả" />);
expect(screen.getByPlaceholderText('Mô tả')).toBeInTheDocument();
});
it('accepts typed input', async () => {
render(<Textarea placeholder="Nhập nội dung" />);
const textarea = screen.getByPlaceholderText('Nhập nội dung');
await userEvent.type(textarea, 'Test content');
expect(textarea).toHaveValue('Test content');
});
it('is disabled when disabled prop is set', () => {
render(<Textarea disabled placeholder="Disabled" />);
expect(screen.getByPlaceholderText('Disabled')).toBeDisabled();
});
it('applies custom className', () => {
render(<Textarea className="tall" placeholder="Custom" />);
expect(screen.getByPlaceholderText('Custom')).toHaveClass('tall');
});
});