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:
35
apps/web/components/ui/__tests__/badge.spec.tsx
Normal file
35
apps/web/components/ui/__tests__/badge.spec.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
60
apps/web/components/ui/__tests__/button.spec.tsx
Normal file
60
apps/web/components/ui/__tests__/button.spec.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
40
apps/web/components/ui/__tests__/card.spec.tsx
Normal file
40
apps/web/components/ui/__tests__/card.spec.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
71
apps/web/components/ui/__tests__/dialog.spec.tsx
Normal file
71
apps/web/components/ui/__tests__/dialog.spec.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
40
apps/web/components/ui/__tests__/input.spec.tsx
Normal file
40
apps/web/components/ui/__tests__/input.spec.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
25
apps/web/components/ui/__tests__/label.spec.tsx
Normal file
25
apps/web/components/ui/__tests__/label.spec.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
40
apps/web/components/ui/__tests__/select.spec.tsx
Normal file
40
apps/web/components/ui/__tests__/select.spec.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
59
apps/web/components/ui/__tests__/table.spec.tsx
Normal file
59
apps/web/components/ui/__tests__/table.spec.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
28
apps/web/components/ui/__tests__/textarea.spec.tsx
Normal file
28
apps/web/components/ui/__tests__/textarea.spec.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user