diff --git a/apps/api/src/modules/agents/application/__tests__/get-agent-public-profile.handler.spec.ts b/apps/api/src/modules/agents/application/__tests__/get-agent-public-profile.handler.spec.ts new file mode 100644 index 0000000..56416ff --- /dev/null +++ b/apps/api/src/modules/agents/application/__tests__/get-agent-public-profile.handler.spec.ts @@ -0,0 +1,140 @@ +import type { AgentPublicProfileData, IAgentRepository } from '../../domain/repositories/agent.repository'; +import { GetAgentPublicProfileHandler } from '../queries/get-agent-public-profile/get-agent-public-profile.handler'; +import { GetAgentPublicProfileQuery } from '../queries/get-agent-public-profile/get-agent-public-profile.query'; + +describe('GetAgentPublicProfileHandler', () => { + let handler: GetAgentPublicProfileHandler; + let mockAgentRepo: { [K in keyof IAgentRepository]: ReturnType }; + + const mockProfile: AgentPublicProfileData = { + id: 'agent-1', + fullName: 'Nguyễn Văn A', + avatarUrl: 'https://example.com/avatar.jpg', + phone: '0901234567', + email: 'agent@example.com', + agency: 'Công ty BĐS ABC', + licenseNumber: 'BDS-001', + bio: 'Chuyên viên bất động sản khu vực Quận 7', + qualityScore: 85, + totalDeals: 50, + isVerified: true, + serviceAreas: ['Quận 7', 'Quận 2', 'Thủ Đức'], + memberSince: '2024-01-15', + activeListings: [ + { + id: 'listing-1', + transactionType: 'SALE', + priceVND: '5000000000', + status: 'ACTIVE', + property: { + id: 'prop-1', + title: 'Căn hộ cao cấp Quận 7', + propertyType: 'APARTMENT', + address: '123 Nguyễn Hữu Thọ', + district: 'Quận 7', + city: 'TP.HCM', + areaM2: 75, + bedrooms: 2, + bathrooms: 2, + imageUrl: 'https://example.com/image.jpg', + }, + }, + ], + avgReviewRating: 4.5, + totalReviews: 20, + }; + + beforeEach(() => { + mockAgentRepo = { + findByUserId: vi.fn(), + findById: vi.fn(), + save: vi.fn(), + getDashboard: vi.fn(), + getPublicProfile: vi.fn(), + getQualityScoreInputs: vi.fn(), + }; + + handler = new GetAgentPublicProfileHandler(mockAgentRepo as any); + }); + + it('returns full public profile for an agent', async () => { + mockAgentRepo.getPublicProfile.mockResolvedValue(mockProfile); + + const query = new GetAgentPublicProfileQuery('agent-1'); + const result = await handler.execute(query); + + expect(result).toEqual(mockProfile); + expect(result!.id).toBe('agent-1'); + expect(result!.fullName).toBe('Nguyễn Văn A'); + expect(result!.qualityScore).toBe(85); + expect(result!.activeListings).toHaveLength(1); + expect(result!.serviceAreas).toContain('Quận 7'); + expect(mockAgentRepo.getPublicProfile).toHaveBeenCalledWith('agent-1'); + }); + + it('returns null when agent does not exist', async () => { + mockAgentRepo.getPublicProfile.mockResolvedValue(null); + + const query = new GetAgentPublicProfileQuery('nonexistent-agent'); + const result = await handler.execute(query); + + expect(result).toBeNull(); + expect(mockAgentRepo.getPublicProfile).toHaveBeenCalledWith('nonexistent-agent'); + }); + + it('returns profile with empty active listings', async () => { + const profileNoListings: AgentPublicProfileData = { + ...mockProfile, + activeListings: [], + totalDeals: 0, + }; + mockAgentRepo.getPublicProfile.mockResolvedValue(profileNoListings); + + const query = new GetAgentPublicProfileQuery('agent-1'); + const result = await handler.execute(query); + + expect(result!.activeListings).toHaveLength(0); + expect(result!.totalDeals).toBe(0); + }); + + it('returns profile with null optional fields', async () => { + const profileMinimal: AgentPublicProfileData = { + ...mockProfile, + avatarUrl: null, + email: null, + agency: null, + licenseNumber: null, + bio: null, + }; + mockAgentRepo.getPublicProfile.mockResolvedValue(profileMinimal); + + const query = new GetAgentPublicProfileQuery('agent-1'); + const result = await handler.execute(query); + + expect(result!.avatarUrl).toBeNull(); + expect(result!.email).toBeNull(); + expect(result!.agency).toBeNull(); + expect(result!.licenseNumber).toBeNull(); + expect(result!.bio).toBeNull(); + expect(result!.fullName).toBe('Nguyễn Văn A'); + }); + + it('returns profile for unverified agent with zero reviews', async () => { + const unverifiedProfile: AgentPublicProfileData = { + ...mockProfile, + isVerified: false, + avgReviewRating: 0, + totalReviews: 0, + qualityScore: 0, + }; + mockAgentRepo.getPublicProfile.mockResolvedValue(unverifiedProfile); + + const query = new GetAgentPublicProfileQuery('agent-new'); + const result = await handler.execute(query); + + expect(result!.isVerified).toBe(false); + expect(result!.avgReviewRating).toBe(0); + expect(result!.totalReviews).toBe(0); + expect(result!.qualityScore).toBe(0); + }); +}); diff --git a/apps/api/src/modules/agents/presentation/__tests__/agents.controller.spec.ts b/apps/api/src/modules/agents/presentation/__tests__/agents.controller.spec.ts new file mode 100644 index 0000000..61de966 --- /dev/null +++ b/apps/api/src/modules/agents/presentation/__tests__/agents.controller.spec.ts @@ -0,0 +1,110 @@ +import { NotFoundException } from '@nestjs/common'; +import { RecalculateQualityScoreCommand } from '../../application/commands/recalculate-quality-score/recalculate-quality-score.command'; +import { GetAgentDashboardQuery } from '../../application/queries/get-agent-dashboard/get-agent-dashboard.query'; +import { GetAgentPublicProfileQuery } from '../../application/queries/get-agent-public-profile/get-agent-public-profile.query'; +import { AgentsController } from '../controllers/agents.controller'; + +describe('AgentsController', () => { + let controller: AgentsController; + let mockCommandBus: { execute: ReturnType }; + let mockQueryBus: { execute: ReturnType }; + + const mockAgentUser = { sub: 'user-1', phone: '0901234567', role: 'AGENT' }; + const _mockAdminUser = { sub: 'admin-1', phone: '0901234568', role: 'ADMIN' }; + + beforeEach(() => { + mockCommandBus = { execute: vi.fn() }; + mockQueryBus = { execute: vi.fn() }; + controller = new AgentsController(mockCommandBus as any, mockQueryBus as any); + }); + + describe('GET /agents/me/dashboard — getDashboard', () => { + it('dispatches GetAgentDashboardQuery with current user', async () => { + const mockDashboard = { + agentId: 'agent-1', + qualityScore: 85, + totalDeals: 12, + responseTimeAvg: 600, + isVerified: true, + totalLeads: 30, + leadsByStatus: { NEW: 5, CONTACTED: 10 }, + conversionRate: 0.167, + totalInquiries: 45, + unreadInquiries: 3, + totalListings: 15, + activeListings: 10, + avgReviewRating: 4.5, + totalReviews: 20, + }; + mockQueryBus.execute.mockResolvedValue(mockDashboard); + + const result = await controller.getDashboard(mockAgentUser as any); + + expect(mockQueryBus.execute).toHaveBeenCalledWith( + expect.any(GetAgentDashboardQuery), + ); + const query = mockQueryBus.execute.mock.calls[0]![0] as GetAgentDashboardQuery; + expect(query.userId).toBe('user-1'); + expect(result).toEqual(mockDashboard); + }); + }); + + describe('GET /agents/:agentId/profile — getPublicProfile', () => { + it('dispatches GetAgentPublicProfileQuery and returns profile', async () => { + const mockProfile = { + id: 'agent-1', + fullName: 'Nguyễn Văn A', + avatarUrl: null, + phone: '0901234567', + email: null, + agency: null, + licenseNumber: null, + bio: null, + qualityScore: 85, + totalDeals: 12, + isVerified: true, + serviceAreas: ['Quận 7'], + memberSince: '2024-01-15', + activeListings: [], + avgReviewRating: 4.5, + totalReviews: 20, + }; + mockQueryBus.execute.mockResolvedValue(mockProfile); + + const result = await controller.getPublicProfile('agent-1'); + + expect(mockQueryBus.execute).toHaveBeenCalledWith( + expect.any(GetAgentPublicProfileQuery), + ); + const query = mockQueryBus.execute.mock.calls[0]![0] as GetAgentPublicProfileQuery; + expect(query.agentId).toBe('agent-1'); + expect(result).toEqual(mockProfile); + }); + + it('throws NotFoundException when profile is null', async () => { + mockQueryBus.execute.mockResolvedValue(null); + + await expect(controller.getPublicProfile('nonexistent')).rejects.toThrow( + NotFoundException, + ); + await expect(controller.getPublicProfile('nonexistent')).rejects.toThrow( + 'Không tìm thấy môi giới', + ); + }); + }); + + describe('POST /agents/:agentId/recalculate-score — recalculateScore', () => { + it('dispatches RecalculateQualityScoreCommand and returns success message', async () => { + mockCommandBus.execute.mockResolvedValue(undefined); + + const result = await controller.recalculateScore('agent-1'); + + expect(mockCommandBus.execute).toHaveBeenCalledWith( + expect.any(RecalculateQualityScoreCommand), + ); + const cmd = mockCommandBus.execute.mock.calls[0]![0] as RecalculateQualityScoreCommand; + expect(cmd.agentId).toBe('agent-1'); + expect(result).toEqual({ message: 'Quality score recalculated' }); + }); + }); +}); diff --git a/apps/api/src/modules/reviews/application/index.ts b/apps/api/src/modules/reviews/application/index.ts new file mode 100644 index 0000000..ee475f3 --- /dev/null +++ b/apps/api/src/modules/reviews/application/index.ts @@ -0,0 +1,5 @@ +export { CreateReviewCommand } from './commands/create-review/create-review.command'; +export { DeleteReviewCommand } from './commands/delete-review/delete-review.command'; +export { GetReviewsByTargetQuery } from './queries/get-reviews-by-target/get-reviews-by-target.query'; +export { GetReviewsByUserQuery } from './queries/get-reviews-by-user/get-reviews-by-user.query'; +export { GetAverageRatingQuery } from './queries/get-average-rating/get-average-rating.query'; diff --git a/apps/api/src/modules/reviews/domain/entities/index.ts b/apps/api/src/modules/reviews/domain/entities/index.ts new file mode 100644 index 0000000..cd4809d --- /dev/null +++ b/apps/api/src/modules/reviews/domain/entities/index.ts @@ -0,0 +1 @@ +export { ReviewEntity, type ReviewProps } from './review.entity'; diff --git a/apps/api/src/modules/reviews/domain/events/index.ts b/apps/api/src/modules/reviews/domain/events/index.ts new file mode 100644 index 0000000..11e43c7 --- /dev/null +++ b/apps/api/src/modules/reviews/domain/events/index.ts @@ -0,0 +1,2 @@ +export { ReviewCreatedEvent } from './review-created.event'; +export { ReviewDeletedEvent } from './review-deleted.event'; diff --git a/apps/api/src/modules/reviews/domain/index.ts b/apps/api/src/modules/reviews/domain/index.ts new file mode 100644 index 0000000..8726a22 --- /dev/null +++ b/apps/api/src/modules/reviews/domain/index.ts @@ -0,0 +1,4 @@ +export * from './entities'; +export * from './value-objects'; +export * from './events'; +export * from './repositories'; diff --git a/apps/api/src/modules/reviews/domain/repositories/index.ts b/apps/api/src/modules/reviews/domain/repositories/index.ts new file mode 100644 index 0000000..82ecdfc --- /dev/null +++ b/apps/api/src/modules/reviews/domain/repositories/index.ts @@ -0,0 +1,6 @@ +export { + REVIEW_REPOSITORY, + type IReviewRepository, + type PaginatedResult, +} from './review.repository'; +export { type ReviewItemData, type ReviewStatsData } from './review-read.dto'; diff --git a/apps/api/src/modules/reviews/domain/value-objects/index.ts b/apps/api/src/modules/reviews/domain/value-objects/index.ts new file mode 100644 index 0000000..3a61cc2 --- /dev/null +++ b/apps/api/src/modules/reviews/domain/value-objects/index.ts @@ -0,0 +1 @@ +export { Rating } from './rating.vo'; diff --git a/apps/web/components/auth/__tests__/oauth-buttons.spec.tsx b/apps/web/components/auth/__tests__/oauth-buttons.spec.tsx new file mode 100644 index 0000000..3e4a82f --- /dev/null +++ b/apps/web/components/auth/__tests__/oauth-buttons.spec.tsx @@ -0,0 +1,70 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { OAuthButtons } from '../oauth-buttons'; + +describe('OAuthButtons', () => { + it('renders Google button', () => { + render(); + expect(screen.getByRole('button', { name: /Google/i })).toBeInTheDocument(); + }); + + it('renders Zalo button', () => { + render(); + expect(screen.getByRole('button', { name: /Zalo/i })).toBeInTheDocument(); + }); + + it('has two OAuth buttons', () => { + render(); + const buttons = screen.getAllByRole('button'); + expect(buttons).toHaveLength(2); + }); + + it('buttons are type="button" (not submit)', () => { + render(); + const buttons = screen.getAllByRole('button'); + buttons.forEach((btn) => { + expect(btn).toHaveAttribute('type', 'button'); + }); + }); + + it('redirects to Google OAuth URL on click', () => { + const originalHref = window.location.href; + delete (window as unknown as Record)['location']; + Object.defineProperty(window, 'location', { + value: { href: originalHref }, + writable: true, + configurable: true, + }); + + render(); + screen.getByRole('button', { name: /Google/i }).click(); + expect(window.location.href).toContain('/auth/google'); + + // Restore + Object.defineProperty(window, 'location', { + value: { href: originalHref }, + writable: true, + configurable: true, + }); + }); + + it('redirects to Zalo OAuth URL on click', () => { + const originalHref = window.location.href; + delete (window as unknown as Record)['location']; + Object.defineProperty(window, 'location', { + value: { href: originalHref }, + writable: true, + configurable: true, + }); + + render(); + screen.getByRole('button', { name: /Zalo/i }).click(); + expect(window.location.href).toContain('/auth/zalo'); + + Object.defineProperty(window, 'location', { + value: { href: originalHref }, + writable: true, + configurable: true, + }); + }); +}); diff --git a/apps/web/components/comparison/__tests__/add-to-compare-button.spec.tsx b/apps/web/components/comparison/__tests__/add-to-compare-button.spec.tsx new file mode 100644 index 0000000..be3affc --- /dev/null +++ b/apps/web/components/comparison/__tests__/add-to-compare-button.spec.tsx @@ -0,0 +1,56 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; +import { AddToCompareButton } from '../add-to-compare-button'; + +// Mock next-intl +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string) => key, +})); + +// Mock comparison store +vi.mock('@/lib/comparison-store', () => { + let selectedIds: string[] = []; + return { + useComparisonStore: (selector: (state: unknown) => unknown) => { + const state = { + isSelected: (id: string) => selectedIds.includes(id), + addToCompare: vi.fn((id: string) => { selectedIds.push(id); }), + removeFromCompare: vi.fn((id: string) => { selectedIds = selectedIds.filter((i) => i !== id); }), + canAdd: () => selectedIds.length < 5, + }; + return selector(state); + }, + }; +}); + +describe('AddToCompareButton', () => { + it('renders add button in full mode', () => { + render(); + expect(screen.getByRole('button', { name: 'addToCompare' })).toBeInTheDocument(); + }); + + it('renders compact button with aria-label', () => { + render(); + expect(screen.getByRole('button', { name: 'addToCompare' })).toBeInTheDocument(); + }); + + it('shows add text in full mode', () => { + render(); + expect(screen.getByText('addToCompare')).toBeInTheDocument(); + }); + + it('handles click on compact button', async () => { + render(); + const btn = screen.getByRole('button'); + await userEvent.click(btn); + // Should not throw - click handler stops propagation + }); + + it('handles click on full button', async () => { + render(); + const btn = screen.getByRole('button', { name: 'addToCompare' }); + await userEvent.click(btn); + // Should not throw + }); +}); diff --git a/apps/web/components/comparison/__tests__/compare-floating-bar.spec.tsx b/apps/web/components/comparison/__tests__/compare-floating-bar.spec.tsx new file mode 100644 index 0000000..40b6b6f --- /dev/null +++ b/apps/web/components/comparison/__tests__/compare-floating-bar.spec.tsx @@ -0,0 +1,99 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { CompareFloatingBar } from '../compare-floating-bar'; + +// Mock next-intl +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string, params?: Record) => { + const translations: Record = { + selected: `${params?.['count'] ?? 0} of ${params?.['max'] ?? 5} selected`, + removeItem: 'Remove item', + clearAll: 'Clear all', + compareNow: 'Compare now', + needMore: 'Need more', + }; + return translations[key] ?? key; + }, +})); + +// Mock i18n navigation +vi.mock('@/i18n/navigation', () => ({ + Link: ({ children, href }: { children: React.ReactNode; href: string }) => ( + {children} + ), +})); + +const mockStoreState = { + selectedIds: [] as string[], + clearAll: vi.fn(), + removeFromCompare: vi.fn(), + canCompare: () => false, +}; + +// Mock comparison store +vi.mock('@/lib/comparison-store', () => ({ + useComparisonStore: (selector: (state: unknown) => unknown) => selector(mockStoreState), + MAX_COMPARE: 5, +})); + +describe('CompareFloatingBar', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockStoreState.selectedIds = []; + mockStoreState.canCompare = () => false; + }); + + it('renders nothing when no items selected', () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it('renders floating bar when items are selected', () => { + mockStoreState.selectedIds = ['listing-1']; + render(); + expect(screen.getByText('1 of 5 selected')).toBeInTheDocument(); + }); + + it('renders clear all button', () => { + mockStoreState.selectedIds = ['listing-1']; + render(); + expect(screen.getByRole('button', { name: 'Clear all' })).toBeInTheDocument(); + }); + + it('calls clearAll when clear button is clicked', async () => { + mockStoreState.selectedIds = ['listing-1']; + render(); + await userEvent.click(screen.getByRole('button', { name: 'Clear all' })); + expect(mockStoreState.clearAll).toHaveBeenCalled(); + }); + + it('shows disabled compare button when canCompare is false', () => { + mockStoreState.selectedIds = ['listing-1']; + mockStoreState.canCompare = () => false; + render(); + expect(screen.getByRole('button', { name: /Need more/ })).toBeDisabled(); + }); + + it('shows compare now link when canCompare is true', () => { + mockStoreState.selectedIds = ['listing-1', 'listing-2']; + mockStoreState.canCompare = () => true; + render(); + expect(screen.getByText('Compare now')).toBeInTheDocument(); + }); + + it('renders remove buttons for each selected item', () => { + mockStoreState.selectedIds = ['listing-1', 'listing-2']; + render(); + const removeButtons = screen.getAllByRole('button', { name: 'Remove item' }); + expect(removeButtons).toHaveLength(2); + }); + + it('calls removeFromCompare when remove button is clicked', async () => { + mockStoreState.selectedIds = ['listing-1']; + render(); + const removeBtn = screen.getByRole('button', { name: 'Remove item' }); + await userEvent.click(removeBtn); + expect(mockStoreState.removeFromCompare).toHaveBeenCalledWith('listing-1'); + }); +}); diff --git a/apps/web/components/comparison/__tests__/comparison-stats.spec.tsx b/apps/web/components/comparison/__tests__/comparison-stats.spec.tsx new file mode 100644 index 0000000..ef8918d --- /dev/null +++ b/apps/web/components/comparison/__tests__/comparison-stats.spec.tsx @@ -0,0 +1,54 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import type { ComparisonStats } from '@/lib/comparison-store'; +import { ComparisonStatsBanner } from '../comparison-stats'; + +// Mock next-intl +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string, _params?: Record) => { + if (key === 'priceRange') return 'Khoảng giá'; + if (key === 'areaRange') return 'Khoảng diện tích'; + if (key === 'pricePerM2Range') return 'Giá/m²'; + if (key === 'average') return 'Trung bình'; + return key; + }, +})); + +const mockStats: ComparisonStats = { + priceRange: { min: 2_000_000_000, max: 5_000_000_000, avg: 3_500_000_000 }, + areaRange: { min: 60, max: 120, avg: 90 }, + pricePerM2Range: { min: 30_000_000, max: 50_000_000, avg: 40_000_000 }, +}; + +describe('ComparisonStatsBanner', () => { + it('renders price range card', () => { + render(); + expect(screen.getByText('Khoảng giá')).toBeInTheDocument(); + }); + + it('renders area range card', () => { + render(); + expect(screen.getByText('Khoảng diện tích')).toBeInTheDocument(); + }); + + it('renders price per m² card when data available', () => { + render(); + expect(screen.getByText('Giá/m²')).toBeInTheDocument(); + }); + + it('renders min and max area', () => { + render(); + expect(screen.getByText(/60.*120 m²/)).toBeInTheDocument(); + }); + + it('renders average area', () => { + render(); + expect(screen.getByText(/Trung bình.*90 m²/)).toBeInTheDocument(); + }); + + it('hides price per m² card when data is null', () => { + const statsNoM2 = { ...mockStats, pricePerM2Range: null }; + render(); + expect(screen.queryByText('Giá/m²')).not.toBeInTheDocument(); + }); +}); diff --git a/apps/web/components/inquiries/__tests__/inquiry-row.spec.tsx b/apps/web/components/inquiries/__tests__/inquiry-row.spec.tsx new file mode 100644 index 0000000..0c50029 --- /dev/null +++ b/apps/web/components/inquiries/__tests__/inquiry-row.spec.tsx @@ -0,0 +1,112 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; +import type { InquiryReadDto } from '@/lib/inquiries-api'; +import { InquiryStatusBadge, InquiryRow } from '../inquiry-row'; + +describe('InquiryStatusBadge', () => { + it('renders "Đã đọc" when isRead is true', () => { + render(); + expect(screen.getByText('Đã đọc')).toBeInTheDocument(); + }); + + it('renders "Chưa đọc" when isRead is false', () => { + render(); + expect(screen.getByText('Chưa đọc')).toBeInTheDocument(); + }); +}); + +const mockInquiry: InquiryReadDto = { + id: 'inq-1', + listingId: 'listing-1', + listingTitle: 'Căn hộ 2PN Quận 7', + userId: 'user-1', + userName: 'Nguyễn Văn A', + userPhone: '0912345678', + message: 'Tôi muốn xem nhà vào cuối tuần', + phone: null, + isRead: false, + createdAt: '2026-01-15T10:30:00Z', +}; + +describe('InquiryRow', () => { + it('renders inquiry user name', () => { + render( + + + + +
, + ); + expect(screen.getByText('Nguyễn Văn A')).toBeInTheDocument(); + }); + + it('renders inquiry user phone', () => { + render( + + + + +
, + ); + expect(screen.getByText('0912345678')).toBeInTheDocument(); + }); + + it('renders listing title', () => { + render( + + + + +
, + ); + expect(screen.getByText('Căn hộ 2PN Quận 7')).toBeInTheDocument(); + }); + + it('renders inquiry message', () => { + render( + + + + +
, + ); + expect(screen.getByText('Tôi muốn xem nhà vào cuối tuần')).toBeInTheDocument(); + }); + + it('renders unread status badge', () => { + render( + + + + +
, + ); + expect(screen.getByText('Chưa đọc')).toBeInTheDocument(); + }); + + it('renders read status badge when isRead is true', () => { + const readInquiry = { ...mockInquiry, isRead: true }; + render( + + + + +
, + ); + expect(screen.getByText('Đã đọc')).toBeInTheDocument(); + }); + + it('calls onSelect when row is clicked', async () => { + const onSelect = vi.fn(); + render( + + + + +
, + ); + await userEvent.click(screen.getByText('Nguyễn Văn A')); + expect(onSelect).toHaveBeenCalledWith(mockInquiry); + }); +}); diff --git a/apps/web/components/search/__tests__/property-card.spec.tsx b/apps/web/components/search/__tests__/property-card.spec.tsx new file mode 100644 index 0000000..2de0d13 --- /dev/null +++ b/apps/web/components/search/__tests__/property-card.spec.tsx @@ -0,0 +1,199 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import type { ListingDetail } from '@/lib/listings-api'; +import { PropertyCard } from '../property-card'; + +// Mock next/image +vi.mock('next/image', () => ({ + default: (props: Record) => { + return ; + }, +})); + +// Mock next/link +vi.mock('next/link', () => ({ + default: ({ children, href }: { children: React.ReactNode; href: string }) => ( + {children} + ), +})); + +// Mock AddToCompareButton +vi.mock('@/components/comparison/add-to-compare-button', () => ({ + AddToCompareButton: ({ listingId }: { listingId: string }) => ( + + ), +})); + +// Mock image-blur +vi.mock('@/lib/image-blur', () => ({ + shimmerBlurDataURL: () => 'data:image/svg+xml;base64,mock', +})); + +function makeListing(overrides: Partial = {}): ListingDetail { + return { + id: 'listing-1', + status: 'ACTIVE', + transactionType: 'SALE', + priceVND: '3500000000', + pricePerM2: 40_000_000, + rentPriceMonthly: null, + commissionPct: null, + viewCount: 100, + saveCount: 10, + inquiryCount: 5, + publishedAt: '2026-01-01T00:00:00Z', + createdAt: '2025-12-01T00:00:00Z', + property: { + id: 'prop-1', + propertyType: 'APARTMENT', + title: 'Căn hộ 2PN Vinhomes Central Park', + description: 'Căn hộ đẹp view sông', + address: '208 Nguyễn Hữu Cảnh', + ward: 'Phường 22', + district: 'Quận Bình Thạnh', + city: 'Hồ Chí Minh', + areaM2: 75, + bedrooms: 2, + bathrooms: 2, + floors: null, + direction: 'SOUTH', + yearBuilt: 2020, + legalStatus: null, + amenities: [], + projectName: 'Vinhomes Central Park', + latitude: 10.7975, + longitude: 106.721, + media: [ + { id: 'media-1', type: 'image', url: 'https://example.com/img1.jpg', order: 0, caption: null }, + { id: 'media-2', type: 'image', url: 'https://example.com/img2.jpg', order: 1, caption: null }, + ], + }, + seller: { + id: 'seller-1', + fullName: 'Nguyen Van B', + phone: '0912345678', + }, + agent: null, + ...overrides, + }; +} + +describe('PropertyCard', () => { + it('renders property title', () => { + render(); + expect(screen.getByText('Căn hộ 2PN Vinhomes Central Park')).toBeInTheDocument(); + }); + + it('renders formatted price', () => { + render(); + expect(screen.getByText(/3\.5 tỷ/)).toBeInTheDocument(); + }); + + it('renders property address', () => { + render(); + expect(screen.getByText(/208 Nguyễn Hữu Cảnh/)).toBeInTheDocument(); + }); + + it('renders transaction type badge for SALE', () => { + render(); + expect(screen.getByText('Bán')).toBeInTheDocument(); + }); + + it('renders transaction type badge for RENT', () => { + render(); + expect(screen.getByText('Cho thuê')).toBeInTheDocument(); + }); + + it('renders property type badge', () => { + render(); + expect(screen.getByText('Căn hộ')).toBeInTheDocument(); + }); + + it('renders area badge', () => { + render(); + expect(screen.getByText('75 m²')).toBeInTheDocument(); + }); + + it('renders bedrooms badge', () => { + render(); + expect(screen.getByText('2 PN')).toBeInTheDocument(); + }); + + it('renders bathrooms badge', () => { + render(); + expect(screen.getByText('2 PT')).toBeInTheDocument(); + }); + + it('renders direction badge when set', () => { + render(); + expect(screen.getByText('Hướng Nam')).toBeInTheDocument(); + }); + + it('does not render direction badge when null', () => { + const listing = makeListing(); + listing.property.direction = null; + render(); + expect(screen.queryByText(/Hướng/)).not.toBeInTheDocument(); + }); + + it('shows photo count badge when multiple images', () => { + render(); + expect(screen.getByText('2 ảnh')).toBeInTheDocument(); + }); + + it('does not show photo count when single image', () => { + const listing = makeListing(); + listing.property.media = [{ id: 'media-1', type: 'image', url: 'https://example.com/img1.jpg', order: 0, caption: null }]; + render(); + expect(screen.queryByText(/\d+ ảnh/)).not.toBeInTheDocument(); + }); + + it('shows "Chưa có ảnh" placeholder when no media', () => { + const listing = makeListing(); + listing.property.media = []; + render(); + expect(screen.getByText('Chưa có ảnh')).toBeInTheDocument(); + }); + + it('links to listing detail page', () => { + render(); + const link = screen.getByRole('link'); + expect(link).toHaveAttribute('href', '/listings/listing-1'); + }); + + it('renders article with descriptive aria-label', () => { + render(); + expect(screen.getByRole('article')).toHaveAttribute( + 'aria-label', + expect.stringContaining('Căn hộ 2PN Vinhomes Central Park'), + ); + }); + + it('does not render bedrooms badge when null', () => { + const listing = makeListing(); + listing.property.bedrooms = null; + render(); + expect(screen.queryByText(/^\d+ PN$/)).not.toBeInTheDocument(); + }); + + it('does not render bathrooms badge when 0', () => { + const listing = makeListing(); + listing.property.bathrooms = 0; + render(); + expect(screen.queryByText(/PT/)).not.toBeInTheDocument(); + }); + + it('renders compare button', () => { + render(); + expect(screen.getByTestId('compare-btn-listing-1')).toBeInTheDocument(); + }); + + it('shows rent price suffix for rental listings', () => { + const listing = makeListing({ + transactionType: 'RENT', + rentPriceMonthly: '15000000', + }); + render(); + expect(screen.getByText('/tháng')).toBeInTheDocument(); + }); +}); diff --git a/apps/web/components/seo/__tests__/json-ld.spec.tsx b/apps/web/components/seo/__tests__/json-ld.spec.tsx new file mode 100644 index 0000000..a716972 --- /dev/null +++ b/apps/web/components/seo/__tests__/json-ld.spec.tsx @@ -0,0 +1,252 @@ +import { render } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import type { AgentPublicProfile } from '@/lib/agents-api'; +import type { ListingDetail } from '@/lib/listings-api'; +import { JsonLd, generateListingJsonLd, generateBreadcrumbJsonLd, generateWebsiteJsonLd, generateAgentJsonLd } from '../json-ld'; + +// ─── JsonLd component ──────────────────────────────────────── + +describe('JsonLd', () => { + it('renders a script tag with type application/ld+json', () => { + const data = { '@context': 'https://schema.org', '@type': 'WebSite' }; + const { container } = render(); + const script = container.querySelector('script[type="application/ld+json"]'); + expect(script).toBeInTheDocument(); + }); + + it('serializes data as JSON inside the script tag', () => { + const data = { '@context': 'https://schema.org', '@type': 'WebSite', name: 'GoodGo' }; + const { container } = render(); + const script = container.querySelector('script')!; + expect(JSON.parse(script.innerHTML)).toEqual(data); + }); + + it('handles array data', () => { + const data = [ + { '@context': 'https://schema.org', '@type': 'WebSite' }, + { '@context': 'https://schema.org', '@type': 'Organization' }, + ]; + const { container } = render(); + const script = container.querySelector('script')!; + expect(JSON.parse(script.innerHTML)).toHaveLength(2); + }); +}); + +// ─── generateWebsiteJsonLd ────────────────────────────────── + +describe('generateWebsiteJsonLd', () => { + it('generates WebSite schema', () => { + const result = generateWebsiteJsonLd('https://goodgo.vn'); + expect(result['@context']).toBe('https://schema.org'); + expect(result['@type']).toBe('WebSite'); + expect(result.name).toBe('GoodGo'); + expect(result.url).toBe('https://goodgo.vn'); + }); + + it('includes SearchAction', () => { + const result = generateWebsiteJsonLd('https://goodgo.vn'); + expect(result.potentialAction).toBeDefined(); + const action = result.potentialAction as Record; + expect(action['@type']).toBe('SearchAction'); + }); + + it('includes bilingual language', () => { + const result = generateWebsiteJsonLd('https://goodgo.vn'); + expect(result.inLanguage).toEqual(['vi', 'en']); + }); +}); + +// ─── generateBreadcrumbJsonLd ─────────────────────────────── + +describe('generateBreadcrumbJsonLd', () => { + it('generates BreadcrumbList schema', () => { + const items = [ + { name: 'Trang chủ', url: 'https://goodgo.vn' }, + { name: 'Tìm kiếm', url: 'https://goodgo.vn/search' }, + ]; + const result = generateBreadcrumbJsonLd(items); + expect(result['@type']).toBe('BreadcrumbList'); + expect(result.itemListElement).toHaveLength(2); + }); + + it('sets correct positions starting from 1', () => { + const items = [ + { name: 'Home', url: 'https://goodgo.vn' }, + { name: 'Search', url: 'https://goodgo.vn/search' }, + ]; + const result = generateBreadcrumbJsonLd(items); + const elements = result.itemListElement as Array>; + expect(elements[0]?.['position']).toBe(1); + expect(elements[1]?.['position']).toBe(2); + }); + + it('returns empty list for no items', () => { + const result = generateBreadcrumbJsonLd([]); + expect(result.itemListElement).toEqual([]); + }); +}); + +// ─── generateListingJsonLd ────────────────────────────────── + +describe('generateListingJsonLd', () => { + const mockListing: ListingDetail = { + id: 'listing-1', + status: 'ACTIVE', + transactionType: 'SALE', + priceVND: '3500000000', + pricePerM2: 40_000_000, + rentPriceMonthly: null, + commissionPct: null, + viewCount: 10, + saveCount: 5, + inquiryCount: 3, + publishedAt: '2026-01-01T00:00:00Z', + createdAt: '2025-12-01T00:00:00Z', + property: { + id: 'prop-1', + propertyType: 'APARTMENT', + title: 'Căn hộ 2PN', + description: 'Mô tả căn hộ đẹp', + 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: [], + projectName: null, + latitude: 10.73, + longitude: 106.72, + media: [{ id: 'media-1', type: 'image', url: 'https://example.com/img1.jpg', order: 0, caption: null }], + }, + seller: { id: 'seller-1', fullName: 'Seller', phone: '0912345678' }, + agent: null, + }; + + it('generates RealEstateListing schema type', () => { + const result = generateListingJsonLd(mockListing, 'https://goodgo.vn'); + expect(result['@type']).toBe('RealEstateListing'); + }); + + it('sets correct URL', () => { + const result = generateListingJsonLd(mockListing, 'https://goodgo.vn'); + expect(result['url']).toBe('https://goodgo.vn/listings/listing-1'); + }); + + it('includes offer with VND price', () => { + const result = generateListingJsonLd(mockListing, 'https://goodgo.vn'); + const offers = result['offers'] as Record; + expect(offers['price']).toBe(3_500_000_000); + expect(offers['priceCurrency']).toBe('VND'); + }); + + it('sets InStock availability for ACTIVE listing', () => { + const result = generateListingJsonLd(mockListing, 'https://goodgo.vn'); + const offers = result['offers'] as Record; + expect(offers['availability']).toBe('https://schema.org/InStock'); + }); + + it('sets SoldOut availability for non-ACTIVE listing', () => { + const soldListing = { ...mockListing, status: 'SOLD' as const }; + const result = generateListingJsonLd(soldListing, 'https://goodgo.vn'); + const offers = result['offers'] as Record; + expect(offers['availability']).toBe('https://schema.org/SoldOut'); + }); + + it('includes geo coordinates when available', () => { + const result = generateListingJsonLd(mockListing, 'https://goodgo.vn'); + const location = result['contentLocation'] as Record; + const geo = location['geo'] as Record; + expect(geo['latitude']).toBe(10.73); + expect(geo['longitude']).toBe(106.72); + }); + + it('includes property area in additionalProperty', () => { + const result = generateListingJsonLd(mockListing, 'https://goodgo.vn'); + const additionalProps = result['additionalProperty'] as Array>; + const areaEntry = additionalProps.find((p) => p['name'] === 'Dien tich'); + expect(areaEntry?.['value']).toBe('75 m\u00B2'); + }); + + it('includes bedrooms when present', () => { + const result = generateListingJsonLd(mockListing, 'https://goodgo.vn'); + const additionalProps = result['additionalProperty'] as Array>; + const bedEntry = additionalProps.find((p) => p['name'] === 'Phong ngu'); + expect(bedEntry?.['value']).toBe(2); + }); + + it('excludes bedrooms when null', () => { + const noBedListing = { + ...mockListing, + property: { ...mockListing.property, bedrooms: null }, + }; + const result = generateListingJsonLd(noBedListing, 'https://goodgo.vn'); + const additionalProps = result['additionalProperty'] as Array>; + const bedEntry = additionalProps.find((p) => p['name'] === 'Phong ngu'); + expect(bedEntry).toBeUndefined(); + }); +}); + +// ─── generateAgentJsonLd ──────────────────────────────────── + +describe('generateAgentJsonLd', () => { + const mockAgent: AgentPublicProfile = { + id: 'agent-1', + fullName: 'Nguyen Van C', + phone: '0912345678', + email: 'agent@goodgo.vn', + avatarUrl: 'https://example.com/avatar.jpg', + bio: 'Agent chuyên nghiệp', + agency: 'GoodGo Agency', + licenseNumber: null, + serviceAreas: ['Quận 1', 'Quận 7'], + activeListings: [], + totalReviews: 20, + avgReviewRating: 4.5, + qualityScore: 95, + totalDeals: 50, + isVerified: true, + memberSince: '2024-01-01T00:00:00Z', + }; + + it('generates RealEstateAgent schema type', () => { + const result = generateAgentJsonLd(mockAgent, 'https://goodgo.vn'); + expect(result['@type']).toBe('RealEstateAgent'); + }); + + it('includes agent name', () => { + const result = generateAgentJsonLd(mockAgent, 'https://goodgo.vn'); + expect(result['name']).toBe('Nguyen Van C'); + }); + + it('includes aggregate rating when reviews exist', () => { + const result = generateAgentJsonLd(mockAgent, 'https://goodgo.vn'); + const rating = result['aggregateRating'] as Record; + expect(rating['ratingValue']).toBe(4.5); + expect(rating['reviewCount']).toBe(20); + }); + + it('excludes aggregate rating when no reviews', () => { + const noReviewAgent = { ...mockAgent, totalReviews: 0 }; + const result = generateAgentJsonLd(noReviewAgent, 'https://goodgo.vn'); + expect(result['aggregateRating']).toBeUndefined(); + }); + + it('includes service areas', () => { + const result = generateAgentJsonLd(mockAgent, 'https://goodgo.vn'); + const areas = result['areaServed'] as Array>; + expect(areas).toHaveLength(2); + expect(areas[0]?.['name']).toBe('Quận 1'); + }); + + it('includes agency as worksFor', () => { + const result = generateAgentJsonLd(mockAgent, 'https://goodgo.vn'); + const worksFor = result['worksFor'] as Record; + expect(worksFor['name']).toBe('GoodGo Agency'); + }); +});