fix: resolve all ESLint errors and TypeScript compilation errors
- Auto-fixed 712 import ordering errors via `pnpm lint --fix` - Manually fixed 13 remaining ESLint errors: - Prefixed unused vars with _ (mockAdminUser, params) - Removed unused imports (UnauthorizedException, vi, screen) - Moved imports above vi.mock() calls to fix import group ordering - Removed eslint-disable for non-existent rules - Fixed empty object pattern in Playwright fixture - Fixed ~40 TypeScript TS4111 index signature errors in test files: - Used bracket notation for Record<string, unknown> property access - Added missing PropertyMedia fields (id, order, caption) to test data - Fixed pre-existing test failures in rate-limit guard specs: - Added NODE_ENV override to bypass test-mode skip in guard Both `pnpm lint` and `pnpm typecheck` now exit 0 cleanly. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -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<typeof vi.fn> };
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<typeof vi.fn> };
|
||||
let mockQueryBus: { execute: ReturnType<typeof vi.fn> };
|
||||
|
||||
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' });
|
||||
});
|
||||
});
|
||||
});
|
||||
5
apps/api/src/modules/reviews/application/index.ts
Normal file
5
apps/api/src/modules/reviews/application/index.ts
Normal file
@@ -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';
|
||||
1
apps/api/src/modules/reviews/domain/entities/index.ts
Normal file
1
apps/api/src/modules/reviews/domain/entities/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { ReviewEntity, type ReviewProps } from './review.entity';
|
||||
2
apps/api/src/modules/reviews/domain/events/index.ts
Normal file
2
apps/api/src/modules/reviews/domain/events/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { ReviewCreatedEvent } from './review-created.event';
|
||||
export { ReviewDeletedEvent } from './review-deleted.event';
|
||||
4
apps/api/src/modules/reviews/domain/index.ts
Normal file
4
apps/api/src/modules/reviews/domain/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './entities';
|
||||
export * from './value-objects';
|
||||
export * from './events';
|
||||
export * from './repositories';
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
REVIEW_REPOSITORY,
|
||||
type IReviewRepository,
|
||||
type PaginatedResult,
|
||||
} from './review.repository';
|
||||
export { type ReviewItemData, type ReviewStatsData } from './review-read.dto';
|
||||
@@ -0,0 +1 @@
|
||||
export { Rating } from './rating.vo';
|
||||
70
apps/web/components/auth/__tests__/oauth-buttons.spec.tsx
Normal file
70
apps/web/components/auth/__tests__/oauth-buttons.spec.tsx
Normal file
@@ -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(<OAuthButtons />);
|
||||
expect(screen.getByRole('button', { name: /Google/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Zalo button', () => {
|
||||
render(<OAuthButtons />);
|
||||
expect(screen.getByRole('button', { name: /Zalo/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('has two OAuth buttons', () => {
|
||||
render(<OAuthButtons />);
|
||||
const buttons = screen.getAllByRole('button');
|
||||
expect(buttons).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('buttons are type="button" (not submit)', () => {
|
||||
render(<OAuthButtons />);
|
||||
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<string, unknown>)['location'];
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { href: originalHref },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
render(<OAuthButtons />);
|
||||
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<string, unknown>)['location'];
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { href: originalHref },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
render(<OAuthButtons />);
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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(<AddToCompareButton listingId="listing-1" />);
|
||||
expect(screen.getByRole('button', { name: 'addToCompare' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders compact button with aria-label', () => {
|
||||
render(<AddToCompareButton listingId="listing-1" compact />);
|
||||
expect(screen.getByRole('button', { name: 'addToCompare' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows add text in full mode', () => {
|
||||
render(<AddToCompareButton listingId="listing-1" />);
|
||||
expect(screen.getByText('addToCompare')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles click on compact button', async () => {
|
||||
render(<AddToCompareButton listingId="listing-2" compact />);
|
||||
const btn = screen.getByRole('button');
|
||||
await userEvent.click(btn);
|
||||
// Should not throw - click handler stops propagation
|
||||
});
|
||||
|
||||
it('handles click on full button', async () => {
|
||||
render(<AddToCompareButton listingId="listing-3" />);
|
||||
const btn = screen.getByRole('button', { name: 'addToCompare' });
|
||||
await userEvent.click(btn);
|
||||
// Should not throw
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>) => {
|
||||
const translations: Record<string, string> = {
|
||||
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 }) => (
|
||||
<a href={href}>{children}</a>
|
||||
),
|
||||
}));
|
||||
|
||||
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(<CompareFloatingBar />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('renders floating bar when items are selected', () => {
|
||||
mockStoreState.selectedIds = ['listing-1'];
|
||||
render(<CompareFloatingBar />);
|
||||
expect(screen.getByText('1 of 5 selected')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders clear all button', () => {
|
||||
mockStoreState.selectedIds = ['listing-1'];
|
||||
render(<CompareFloatingBar />);
|
||||
expect(screen.getByRole('button', { name: 'Clear all' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls clearAll when clear button is clicked', async () => {
|
||||
mockStoreState.selectedIds = ['listing-1'];
|
||||
render(<CompareFloatingBar />);
|
||||
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(<CompareFloatingBar />);
|
||||
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(<CompareFloatingBar />);
|
||||
expect(screen.getByText('Compare now')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders remove buttons for each selected item', () => {
|
||||
mockStoreState.selectedIds = ['listing-1', 'listing-2'];
|
||||
render(<CompareFloatingBar />);
|
||||
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(<CompareFloatingBar />);
|
||||
const removeBtn = screen.getByRole('button', { name: 'Remove item' });
|
||||
await userEvent.click(removeBtn);
|
||||
expect(mockStoreState.removeFromCompare).toHaveBeenCalledWith('listing-1');
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>) => {
|
||||
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(<ComparisonStatsBanner stats={mockStats} />);
|
||||
expect(screen.getByText('Khoảng giá')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders area range card', () => {
|
||||
render(<ComparisonStatsBanner stats={mockStats} />);
|
||||
expect(screen.getByText('Khoảng diện tích')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders price per m² card when data available', () => {
|
||||
render(<ComparisonStatsBanner stats={mockStats} />);
|
||||
expect(screen.getByText('Giá/m²')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders min and max area', () => {
|
||||
render(<ComparisonStatsBanner stats={mockStats} />);
|
||||
expect(screen.getByText(/60.*120 m²/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders average area', () => {
|
||||
render(<ComparisonStatsBanner stats={mockStats} />);
|
||||
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(<ComparisonStatsBanner stats={statsNoM2} />);
|
||||
expect(screen.queryByText('Giá/m²')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
112
apps/web/components/inquiries/__tests__/inquiry-row.spec.tsx
Normal file
112
apps/web/components/inquiries/__tests__/inquiry-row.spec.tsx
Normal file
@@ -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(<InquiryStatusBadge isRead={true} />);
|
||||
expect(screen.getByText('Đã đọc')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders "Chưa đọc" when isRead is false', () => {
|
||||
render(<InquiryStatusBadge isRead={false} />);
|
||||
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(
|
||||
<table>
|
||||
<tbody>
|
||||
<InquiryRow inquiry={mockInquiry} onSelect={vi.fn()} />
|
||||
</tbody>
|
||||
</table>,
|
||||
);
|
||||
expect(screen.getByText('Nguyễn Văn A')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders inquiry user phone', () => {
|
||||
render(
|
||||
<table>
|
||||
<tbody>
|
||||
<InquiryRow inquiry={mockInquiry} onSelect={vi.fn()} />
|
||||
</tbody>
|
||||
</table>,
|
||||
);
|
||||
expect(screen.getByText('0912345678')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders listing title', () => {
|
||||
render(
|
||||
<table>
|
||||
<tbody>
|
||||
<InquiryRow inquiry={mockInquiry} onSelect={vi.fn()} />
|
||||
</tbody>
|
||||
</table>,
|
||||
);
|
||||
expect(screen.getByText('Căn hộ 2PN Quận 7')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders inquiry message', () => {
|
||||
render(
|
||||
<table>
|
||||
<tbody>
|
||||
<InquiryRow inquiry={mockInquiry} onSelect={vi.fn()} />
|
||||
</tbody>
|
||||
</table>,
|
||||
);
|
||||
expect(screen.getByText('Tôi muốn xem nhà vào cuối tuần')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders unread status badge', () => {
|
||||
render(
|
||||
<table>
|
||||
<tbody>
|
||||
<InquiryRow inquiry={mockInquiry} onSelect={vi.fn()} />
|
||||
</tbody>
|
||||
</table>,
|
||||
);
|
||||
expect(screen.getByText('Chưa đọc')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders read status badge when isRead is true', () => {
|
||||
const readInquiry = { ...mockInquiry, isRead: true };
|
||||
render(
|
||||
<table>
|
||||
<tbody>
|
||||
<InquiryRow inquiry={readInquiry} onSelect={vi.fn()} />
|
||||
</tbody>
|
||||
</table>,
|
||||
);
|
||||
expect(screen.getByText('Đã đọc')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onSelect when row is clicked', async () => {
|
||||
const onSelect = vi.fn();
|
||||
render(
|
||||
<table>
|
||||
<tbody>
|
||||
<InquiryRow inquiry={mockInquiry} onSelect={onSelect} />
|
||||
</tbody>
|
||||
</table>,
|
||||
);
|
||||
await userEvent.click(screen.getByText('Nguyễn Văn A'));
|
||||
expect(onSelect).toHaveBeenCalledWith(mockInquiry);
|
||||
});
|
||||
});
|
||||
199
apps/web/components/search/__tests__/property-card.spec.tsx
Normal file
199
apps/web/components/search/__tests__/property-card.spec.tsx
Normal file
@@ -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<string, unknown>) => {
|
||||
return <img {...props} />;
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock next/link
|
||||
vi.mock('next/link', () => ({
|
||||
default: ({ children, href }: { children: React.ReactNode; href: string }) => (
|
||||
<a href={href}>{children}</a>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock AddToCompareButton
|
||||
vi.mock('@/components/comparison/add-to-compare-button', () => ({
|
||||
AddToCompareButton: ({ listingId }: { listingId: string }) => (
|
||||
<button data-testid={`compare-btn-${listingId}`}>Compare</button>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock image-blur
|
||||
vi.mock('@/lib/image-blur', () => ({
|
||||
shimmerBlurDataURL: () => 'data:image/svg+xml;base64,mock',
|
||||
}));
|
||||
|
||||
function makeListing(overrides: Partial<ListingDetail> = {}): 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(<PropertyCard listing={makeListing()} />);
|
||||
expect(screen.getByText('Căn hộ 2PN Vinhomes Central Park')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders formatted price', () => {
|
||||
render(<PropertyCard listing={makeListing()} />);
|
||||
expect(screen.getByText(/3\.5 tỷ/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders property address', () => {
|
||||
render(<PropertyCard listing={makeListing()} />);
|
||||
expect(screen.getByText(/208 Nguyễn Hữu Cảnh/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders transaction type badge for SALE', () => {
|
||||
render(<PropertyCard listing={makeListing()} />);
|
||||
expect(screen.getByText('Bán')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders transaction type badge for RENT', () => {
|
||||
render(<PropertyCard listing={makeListing({ transactionType: 'RENT' })} />);
|
||||
expect(screen.getByText('Cho thuê')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders property type badge', () => {
|
||||
render(<PropertyCard listing={makeListing()} />);
|
||||
expect(screen.getByText('Căn hộ')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders area badge', () => {
|
||||
render(<PropertyCard listing={makeListing()} />);
|
||||
expect(screen.getByText('75 m²')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders bedrooms badge', () => {
|
||||
render(<PropertyCard listing={makeListing()} />);
|
||||
expect(screen.getByText('2 PN')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders bathrooms badge', () => {
|
||||
render(<PropertyCard listing={makeListing()} />);
|
||||
expect(screen.getByText('2 PT')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders direction badge when set', () => {
|
||||
render(<PropertyCard listing={makeListing()} />);
|
||||
expect(screen.getByText('Hướng Nam')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render direction badge when null', () => {
|
||||
const listing = makeListing();
|
||||
listing.property.direction = null;
|
||||
render(<PropertyCard listing={listing} />);
|
||||
expect(screen.queryByText(/Hướng/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows photo count badge when multiple images', () => {
|
||||
render(<PropertyCard listing={makeListing()} />);
|
||||
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(<PropertyCard listing={listing} />);
|
||||
expect(screen.queryByText(/\d+ ảnh/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "Chưa có ảnh" placeholder when no media', () => {
|
||||
const listing = makeListing();
|
||||
listing.property.media = [];
|
||||
render(<PropertyCard listing={listing} />);
|
||||
expect(screen.getByText('Chưa có ảnh')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('links to listing detail page', () => {
|
||||
render(<PropertyCard listing={makeListing()} />);
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toHaveAttribute('href', '/listings/listing-1');
|
||||
});
|
||||
|
||||
it('renders article with descriptive aria-label', () => {
|
||||
render(<PropertyCard listing={makeListing()} />);
|
||||
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(<PropertyCard listing={listing} />);
|
||||
expect(screen.queryByText(/^\d+ PN$/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render bathrooms badge when 0', () => {
|
||||
const listing = makeListing();
|
||||
listing.property.bathrooms = 0;
|
||||
render(<PropertyCard listing={listing} />);
|
||||
expect(screen.queryByText(/PT/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders compare button', () => {
|
||||
render(<PropertyCard listing={makeListing()} />);
|
||||
expect(screen.getByTestId('compare-btn-listing-1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows rent price suffix for rental listings', () => {
|
||||
const listing = makeListing({
|
||||
transactionType: 'RENT',
|
||||
rentPriceMonthly: '15000000',
|
||||
});
|
||||
render(<PropertyCard listing={listing} />);
|
||||
expect(screen.getByText('/tháng')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
252
apps/web/components/seo/__tests__/json-ld.spec.tsx
Normal file
252
apps/web/components/seo/__tests__/json-ld.spec.tsx
Normal file
@@ -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(<JsonLd data={data} />);
|
||||
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(<JsonLd data={data} />);
|
||||
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(<JsonLd data={data} />);
|
||||
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<string, unknown>;
|
||||
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<Record<string, unknown>>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
const geo = location['geo'] as Record<string, unknown>;
|
||||
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<Record<string, unknown>>;
|
||||
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<Record<string, unknown>>;
|
||||
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<Record<string, unknown>>;
|
||||
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<string, unknown>;
|
||||
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<Record<string, unknown>>;
|
||||
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<string, unknown>;
|
||||
expect(worksFor['name']).toBe('GoodGo Agency');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user