From ccb82fddf89125da2b95ecbf65cf7b0fce5b30f5 Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Wed, 8 Apr 2026 22:51:16 +0700 Subject: [PATCH] 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 --- apps/web/app/(auth)/__tests__/login.spec.tsx | 145 ++++ .../app/(auth)/__tests__/register.spec.tsx | 145 ++++ .../__tests__/create-listing.spec.tsx | 110 +++ .../(public)/search/__tests__/search.spec.tsx | 140 ++++ .../components/ui/__tests__/badge.spec.tsx | 35 + .../components/ui/__tests__/button.spec.tsx | 60 ++ .../web/components/ui/__tests__/card.spec.tsx | 40 + .../components/ui/__tests__/dialog.spec.tsx | 71 ++ .../components/ui/__tests__/input.spec.tsx | 40 + .../components/ui/__tests__/label.spec.tsx | 25 + .../components/ui/__tests__/select.spec.tsx | 40 + .../components/ui/__tests__/table.spec.tsx | 59 ++ .../components/ui/__tests__/textarea.spec.tsx | 28 + apps/web/lib/__tests__/auth-store.spec.ts | 216 +++++ apps/web/package.json | 6 + apps/web/vitest.config.ts | 7 +- apps/web/vitest.setup.ts | 1 + pnpm-lock.yaml | 736 +++++++++++++++++- 18 files changed, 1885 insertions(+), 19 deletions(-) create mode 100644 apps/web/app/(auth)/__tests__/login.spec.tsx create mode 100644 apps/web/app/(auth)/__tests__/register.spec.tsx create mode 100644 apps/web/app/(dashboard)/listings/__tests__/create-listing.spec.tsx create mode 100644 apps/web/app/(public)/search/__tests__/search.spec.tsx create mode 100644 apps/web/components/ui/__tests__/badge.spec.tsx create mode 100644 apps/web/components/ui/__tests__/button.spec.tsx create mode 100644 apps/web/components/ui/__tests__/card.spec.tsx create mode 100644 apps/web/components/ui/__tests__/dialog.spec.tsx create mode 100644 apps/web/components/ui/__tests__/input.spec.tsx create mode 100644 apps/web/components/ui/__tests__/label.spec.tsx create mode 100644 apps/web/components/ui/__tests__/select.spec.tsx create mode 100644 apps/web/components/ui/__tests__/table.spec.tsx create mode 100644 apps/web/components/ui/__tests__/textarea.spec.tsx create mode 100644 apps/web/lib/__tests__/auth-store.spec.ts create mode 100644 apps/web/vitest.setup.ts diff --git a/apps/web/app/(auth)/__tests__/login.spec.tsx b/apps/web/app/(auth)/__tests__/login.spec.tsx new file mode 100644 index 0000000..a37c995 --- /dev/null +++ b/apps/web/app/(auth)/__tests__/login.spec.tsx @@ -0,0 +1,145 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useAuthStore } from '@/lib/auth-store'; + +// Mock next/navigation +const mockPush = vi.fn(); +const mockSearchParams = new URLSearchParams(); +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: mockPush }), + useSearchParams: () => mockSearchParams, +})); + +// Mock next/link +vi.mock('next/link', () => ({ + default: ({ children, href, ...props }: { children: React.ReactNode; href: string; [key: string]: unknown }) => ( + {children} + ), +})); + +// Mock auth store +vi.mock('@/lib/auth-store', () => { + const store = { + login: vi.fn(), + isLoading: false, + error: null, + clearError: vi.fn(), + }; + return { + useAuthStore: vi.fn((selector) => { + if (typeof selector === 'function') return selector(store); + return store; + }), + }; +}); + +import LoginPage from '../login/page'; + +const mockedUseAuthStore = vi.mocked(useAuthStore); + +describe('LoginPage', () => { + let mockStore: { + login: ReturnType; + isLoading: boolean; + error: string | null; + clearError: ReturnType; + }; + + beforeEach(() => { + vi.clearAllMocks(); + mockStore = { + login: vi.fn(), + isLoading: false, + error: null, + clearError: vi.fn(), + }; + mockedUseAuthStore.mockImplementation((selector) => { + if (typeof selector === 'function') return (selector as (s: typeof mockStore) => unknown)(mockStore); + return mockStore as ReturnType; + }); + }); + + it('renders login form with phone and password fields', () => { + render(); + + expect(screen.getByRole('heading', { name: 'Đăng nhập' })).toBeInTheDocument(); + expect(screen.getByLabelText('Số điện thoại')).toBeInTheDocument(); + expect(screen.getByLabelText('Mật khẩu')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /đăng nhập/i })).toBeInTheDocument(); + }); + + it('renders OAuth buttons', () => { + render(); + + expect(screen.getByRole('button', { name: /google/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /zalo/i })).toBeInTheDocument(); + }); + + it('renders register link', () => { + render(); + + const registerLink = screen.getByRole('link', { name: /đăng ký/i }); + expect(registerLink).toHaveAttribute('href', '/register'); + }); + + it('submits form with valid data', async () => { + mockStore.login.mockResolvedValue(undefined); + render(); + + await userEvent.type(screen.getByLabelText('Số điện thoại'), '0912345678'); + await userEvent.type(screen.getByLabelText('Mật khẩu'), 'password123'); + await userEvent.click(screen.getByRole('button', { name: /đăng nhập/i })); + + await waitFor(() => { + expect(mockStore.login).toHaveBeenCalledWith({ + phone: '0912345678', + password: 'password123', + }); + }); + }); + + it('shows validation errors for empty fields', async () => { + render(); + + await userEvent.click(screen.getByRole('button', { name: /đăng nhập/i })); + + await waitFor(() => { + const alerts = screen.getAllByRole('alert'); + expect(alerts.length).toBeGreaterThan(0); + }); + }); + + it('toggles password visibility', async () => { + render(); + + const passwordInput = screen.getByLabelText('Mật khẩu'); + expect(passwordInput).toHaveAttribute('type', 'password'); + + await userEvent.click(screen.getByText('Hiện')); + expect(passwordInput).toHaveAttribute('type', 'text'); + + await userEvent.click(screen.getByText('Ẩn')); + expect(passwordInput).toHaveAttribute('type', 'password'); + }); + + it('displays store error message', () => { + mockStore.error = 'Sai mật khẩu'; + render(); + + expect(screen.getByText('Sai mật khẩu')).toBeInTheDocument(); + }); + + it('navigates to home after successful login', async () => { + mockStore.login.mockResolvedValue(undefined); + render(); + + await userEvent.type(screen.getByLabelText('Số điện thoại'), '0912345678'); + await userEvent.type(screen.getByLabelText('Mật khẩu'), 'password123'); + await userEvent.click(screen.getByRole('button', { name: /đăng nhập/i })); + + await waitFor(() => { + expect(mockPush).toHaveBeenCalledWith('/'); + }); + }); +}); diff --git a/apps/web/app/(auth)/__tests__/register.spec.tsx b/apps/web/app/(auth)/__tests__/register.spec.tsx new file mode 100644 index 0000000..a52dbdc --- /dev/null +++ b/apps/web/app/(auth)/__tests__/register.spec.tsx @@ -0,0 +1,145 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useAuthStore } from '@/lib/auth-store'; + +const mockPush = vi.fn(); +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: mockPush }), +})); + +vi.mock('next/link', () => ({ + default: ({ children, href, ...props }: { children: React.ReactNode; href: string; [key: string]: unknown }) => ( + {children} + ), +})); + +vi.mock('@/lib/auth-store', () => { + const store = { + register: vi.fn(), + isLoading: false, + error: null, + clearError: vi.fn(), + }; + return { + useAuthStore: vi.fn((selector) => { + if (typeof selector === 'function') return selector(store); + return store; + }), + }; +}); + +import RegisterPage from '../register/page'; + +const mockedUseAuthStore = vi.mocked(useAuthStore); + +describe('RegisterPage', () => { + let mockStore: { + register: ReturnType; + isLoading: boolean; + error: string | null; + clearError: ReturnType; + }; + + beforeEach(() => { + vi.clearAllMocks(); + mockStore = { + register: vi.fn(), + isLoading: false, + error: null, + clearError: vi.fn(), + }; + mockedUseAuthStore.mockImplementation((selector) => { + if (typeof selector === 'function') return (selector as (s: typeof mockStore) => unknown)(mockStore); + return mockStore as ReturnType; + }); + }); + + it('renders register form with all fields', () => { + render(); + + expect(screen.getByText('Tạo tài khoản')).toBeInTheDocument(); + expect(screen.getByLabelText('Họ và tên')).toBeInTheDocument(); + expect(screen.getByLabelText('Số điện thoại')).toBeInTheDocument(); + expect(screen.getByLabelText(/email/i)).toBeInTheDocument(); + expect(screen.getByLabelText('Mật khẩu')).toBeInTheDocument(); + expect(screen.getByLabelText('Xác nhận mật khẩu')).toBeInTheDocument(); + }); + + it('renders login link', () => { + render(); + const loginLink = screen.getByRole('link', { name: /đăng nhập/i }); + expect(loginLink).toHaveAttribute('href', '/login'); + }); + + it('submits form with valid data', async () => { + mockStore.register.mockResolvedValue(undefined); + render(); + + await userEvent.type(screen.getByLabelText('Họ và tên'), 'Nguyen Van A'); + await userEvent.type(screen.getByLabelText('Số điện thoại'), '0912345678'); + await userEvent.type(screen.getByLabelText('Mật khẩu'), 'password123'); + await userEvent.type(screen.getByLabelText('Xác nhận mật khẩu'), 'password123'); + await userEvent.click(screen.getByRole('button', { name: /đăng ký/i })); + + await waitFor(() => { + expect(mockStore.register).toHaveBeenCalledWith({ + phone: '0912345678', + password: 'password123', + fullName: 'Nguyen Van A', + email: undefined, + }); + }); + }); + + it('shows validation error for short password', async () => { + render(); + + await userEvent.type(screen.getByLabelText('Họ và tên'), 'Nguyen Van A'); + await userEvent.type(screen.getByLabelText('Số điện thoại'), '0912345678'); + await userEvent.type(screen.getByLabelText('Mật khẩu'), 'short'); + await userEvent.type(screen.getByLabelText('Xác nhận mật khẩu'), 'short'); + await userEvent.click(screen.getByRole('button', { name: /đăng ký/i })); + + await waitFor(() => { + const alerts = screen.getAllByRole('alert'); + expect(alerts.length).toBeGreaterThan(0); + }); + }); + + it('shows error when passwords do not match', async () => { + render(); + + await userEvent.type(screen.getByLabelText('Họ và tên'), 'Nguyen Van A'); + await userEvent.type(screen.getByLabelText('Số điện thoại'), '0912345678'); + await userEvent.type(screen.getByLabelText('Mật khẩu'), 'password123'); + await userEvent.type(screen.getByLabelText('Xác nhận mật khẩu'), 'differentpw'); + await userEvent.click(screen.getByRole('button', { name: /đăng ký/i })); + + await waitFor(() => { + const alerts = screen.getAllByRole('alert'); + expect(alerts.length).toBeGreaterThan(0); + }); + }); + + it('displays store error message', () => { + mockStore.error = 'Số điện thoại đã tồn tại'; + render(); + expect(screen.getByText('Số điện thoại đã tồn tại')).toBeInTheDocument(); + }); + + it('navigates to home after successful registration', async () => { + mockStore.register.mockResolvedValue(undefined); + render(); + + await userEvent.type(screen.getByLabelText('Họ và tên'), 'Nguyen Van A'); + await userEvent.type(screen.getByLabelText('Số điện thoại'), '0912345678'); + await userEvent.type(screen.getByLabelText('Mật khẩu'), 'password123'); + await userEvent.type(screen.getByLabelText('Xác nhận mật khẩu'), 'password123'); + await userEvent.click(screen.getByRole('button', { name: /đăng ký/i })); + + await waitFor(() => { + expect(mockPush).toHaveBeenCalledWith('/'); + }); + }); +}); diff --git a/apps/web/app/(dashboard)/listings/__tests__/create-listing.spec.tsx b/apps/web/app/(dashboard)/listings/__tests__/create-listing.spec.tsx new file mode 100644 index 0000000..38cee0c --- /dev/null +++ b/apps/web/app/(dashboard)/listings/__tests__/create-listing.spec.tsx @@ -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 }) => ( +
+ +
+ ), +})); + +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(); + + 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(); + + 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(); + 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(); + + // 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(); + + 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(); + + // 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(); + }); + }); +}); diff --git a/apps/web/app/(public)/search/__tests__/search.spec.tsx b/apps/web/app/(public)/search/__tests__/search.spec.tsx new file mode 100644 index 0000000..2fb0cf4 --- /dev/null +++ b/apps/web/app/(public)/search/__tests__/search.spec.tsx @@ -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 }) => ( + {children} + ), +})); + +vi.mock('next/image', () => ({ + default: (props: Record) => , +})); + +// Mock dynamic import for map component +vi.mock('next/dynamic', () => ({ + default: () => { + const MockMap = () =>
Map
; + 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(); + + await waitFor(() => { + expect(screen.getByText('Tìm kiếm bất động sản')).toBeInTheDocument(); + }); + }); + + it('renders view mode toggle buttons', async () => { + render(); + + 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(); + + await waitFor(() => { + expect(mockedListingsApi.search).toHaveBeenCalled(); + }); + }); + + it('displays listing results after loading', async () => { + render(); + + 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(); + + 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(); + }); + }); +}); diff --git a/apps/web/components/ui/__tests__/badge.spec.tsx b/apps/web/components/ui/__tests__/badge.spec.tsx new file mode 100644 index 0000000..b9a7e98 --- /dev/null +++ b/apps/web/components/ui/__tests__/badge.spec.tsx @@ -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(Active); + expect(screen.getByText('Active')).toBeInTheDocument(); + }); + + it('applies default variant styles', () => { + render(Default); + expect(screen.getByTestId('badge')).toHaveClass('bg-primary'); + }); + + it('applies destructive variant', () => { + render(Error); + expect(screen.getByTestId('badge')).toHaveClass('bg-destructive'); + }); + + it('applies success variant', () => { + render(OK); + expect(screen.getByTestId('badge')).toHaveClass('bg-green-100'); + }); + + it('applies warning variant', () => { + render(Warn); + expect(screen.getByTestId('badge')).toHaveClass('bg-yellow-100'); + }); + + it('applies custom className', () => { + render(Custom); + expect(screen.getByTestId('badge')).toHaveClass('extra'); + }); +}); diff --git a/apps/web/components/ui/__tests__/button.spec.tsx b/apps/web/components/ui/__tests__/button.spec.tsx new file mode 100644 index 0000000..23e1dae --- /dev/null +++ b/apps/web/components/ui/__tests__/button.spec.tsx @@ -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(); + expect(screen.getByRole('button', { name: 'Click me' })).toBeInTheDocument(); + }); + + it('handles click events', async () => { + const onClick = vi.fn(); + render(); + await userEvent.click(screen.getByRole('button')); + expect(onClick).toHaveBeenCalledOnce(); + }); + + it('is disabled when disabled prop is set', () => { + render(); + expect(screen.getByRole('button')).toBeDisabled(); + }); + + it('does not fire click when disabled', async () => { + const onClick = vi.fn(); + render(); + await userEvent.click(screen.getByRole('button')); + expect(onClick).not.toHaveBeenCalled(); + }); + + it('applies variant classes for destructive', () => { + render(); + expect(screen.getByRole('button')).toHaveClass('bg-destructive'); + }); + + it('applies variant classes for outline', () => { + render(); + expect(screen.getByRole('button')).toHaveClass('border'); + }); + + it('applies size classes for sm', () => { + render(); + expect(screen.getByRole('button')).toHaveClass('h-9'); + }); + + it('applies size classes for lg', () => { + render(); + expect(screen.getByRole('button')).toHaveClass('h-11'); + }); + + it('applies custom className', () => { + render(); + expect(screen.getByRole('button')).toHaveClass('custom-class'); + }); + + it('renders as submit button when type is set', () => { + render(); + expect(screen.getByRole('button')).toHaveAttribute('type', 'submit'); + }); +}); diff --git a/apps/web/components/ui/__tests__/card.spec.tsx b/apps/web/components/ui/__tests__/card.spec.tsx new file mode 100644 index 0000000..61af027 --- /dev/null +++ b/apps/web/components/ui/__tests__/card.spec.tsx @@ -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( + + + Title + Description + + Content + Footer + , + ); + + 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(Content); + expect(screen.getByTestId('card')).toHaveClass('custom'); + expect(screen.getByTestId('card')).toHaveClass('rounded-lg'); + }); + + it('renders CardTitle as h3', () => { + render(My Title); + expect(screen.getByRole('heading', { level: 3 })).toHaveTextContent('My Title'); + }); + + it('renders CardDescription as paragraph', () => { + render(My Description); + expect(screen.getByText('My Description').tagName).toBe('P'); + }); +}); diff --git a/apps/web/components/ui/__tests__/dialog.spec.tsx b/apps/web/components/ui/__tests__/dialog.spec.tsx new file mode 100644 index 0000000..6e0b7af --- /dev/null +++ b/apps/web/components/ui/__tests__/dialog.spec.tsx @@ -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( + {}}> + + Hidden + + , + ); + expect(screen.queryByText('Hidden')).not.toBeInTheDocument(); + }); + + it('renders content when open is true', () => { + render( + {}}> + + + Test Dialog + Dialog description + +

Body content

+ + + +
+
, + ); + + 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( + + + Closeable + + , + ); + + // 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( + + + Stay Open + + , + ); + + await userEvent.click(screen.getByText('Stay Open')); + expect(onOpenChange).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/components/ui/__tests__/input.spec.tsx b/apps/web/components/ui/__tests__/input.spec.tsx new file mode 100644 index 0000000..c7a44b2 --- /dev/null +++ b/apps/web/components/ui/__tests__/input.spec.tsx @@ -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(); + expect(screen.getByPlaceholderText('Enter text')).toBeInTheDocument(); + }); + + it('accepts and displays typed value', async () => { + render(); + const input = screen.getByPlaceholderText('Type here'); + await userEvent.type(input, 'Hello'); + expect(input).toHaveValue('Hello'); + }); + + it('applies type attribute', () => { + render(); + expect(screen.getByPlaceholderText('Email')).toHaveAttribute('type', 'email'); + }); + + it('is disabled when disabled prop is set', () => { + render(); + expect(screen.getByPlaceholderText('Disabled')).toBeDisabled(); + }); + + it('calls onChange handler', async () => { + const onChange = vi.fn(); + render(); + await userEvent.type(screen.getByPlaceholderText('Input'), 'a'); + expect(onChange).toHaveBeenCalled(); + }); + + it('applies custom className', () => { + render(); + expect(screen.getByPlaceholderText('Custom')).toHaveClass('my-class'); + }); +}); diff --git a/apps/web/components/ui/__tests__/label.spec.tsx b/apps/web/components/ui/__tests__/label.spec.tsx new file mode 100644 index 0000000..4c2680e --- /dev/null +++ b/apps/web/components/ui/__tests__/label.spec.tsx @@ -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(); + expect(screen.getByText('Số điện thoại')).toBeInTheDocument(); + }); + + it('associates with input via htmlFor', () => { + render( + <> + + + , + ); + expect(screen.getByLabelText('Phone')).toBeInTheDocument(); + }); + + it('applies custom className', () => { + render(); + expect(screen.getByTestId('label')).toHaveClass('custom'); + }); +}); diff --git a/apps/web/components/ui/__tests__/select.spec.tsx b/apps/web/components/ui/__tests__/select.spec.tsx new file mode 100644 index 0000000..f2ddd64 --- /dev/null +++ b/apps/web/components/ui/__tests__/select.spec.tsx @@ -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( + , + ); + expect(screen.getByRole('combobox', { name: 'Property type' })).toBeInTheDocument(); + expect(screen.getAllByRole('option')).toHaveLength(3); + }); + + it('handles value change', async () => { + const onChange = vi.fn(); + render( + , + ); + await userEvent.selectOptions(screen.getByRole('combobox'), 'SALE'); + expect(onChange).toHaveBeenCalled(); + }); + + it('is disabled when disabled prop is set', () => { + render( + , + ); + expect(screen.getByRole('combobox')).toBeDisabled(); + }); +}); diff --git a/apps/web/components/ui/__tests__/table.spec.tsx b/apps/web/components/ui/__tests__/table.spec.tsx new file mode 100644 index 0000000..e1ff698 --- /dev/null +++ b/apps/web/components/ui/__tests__/table.spec.tsx @@ -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( + + + + Name + Price + + + + + Apartment + 1,000,000 VND + + +
, + ); + + 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( + + + Row 1 + Row 2 + Row 3 + +
, + ); + + expect(screen.getAllByRole('row')).toHaveLength(3); + }); + + it('applies custom className to table elements', () => { + render( + + + + Data + + +
, + ); + + expect(screen.getByTestId('row')).toHaveClass('highlight'); + expect(screen.getByTestId('cell')).toHaveClass('bold'); + }); +}); diff --git a/apps/web/components/ui/__tests__/textarea.spec.tsx b/apps/web/components/ui/__tests__/textarea.spec.tsx new file mode 100644 index 0000000..f9b2248 --- /dev/null +++ b/apps/web/components/ui/__tests__/textarea.spec.tsx @@ -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(