- Fix TS4111: use bracket notation for index signature access in metadata.spec.ts, neighborhood-poi-map.tsx, and neighborhood-poi-map.spec.tsx - Fix TS2740: add missing property fields (usableAreaM2, floor, totalFloors, nearbyPOIs, etc.) to test mock objects in 5 spec files - Fix TS2339: add missing estimate() and create() methods to transferApi - Fix TS4114: add override modifier to render() in page.tsx error boundary - Fix TS2532: add optional chaining for possibly undefined features in neighborhood-poi-map.tsx Co-Authored-By: Paperclip <noreply@paperclip.ing>
270 lines
10 KiB
TypeScript
270 lines
10 KiB
TypeScript
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',
|
|
valuationEstimate: null,
|
|
agentQualityScore: null,
|
|
similarCount: 0,
|
|
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 }],
|
|
usableAreaM2: null,
|
|
floor: null,
|
|
totalFloors: null,
|
|
nearbyPOIs: null,
|
|
metroDistanceM: null,
|
|
furnishing: null,
|
|
propertyCondition: null,
|
|
balconyDirection: null,
|
|
maintenanceFeeVND: null,
|
|
parkingSlots: null,
|
|
viewType: [],
|
|
petFriendly: null,
|
|
suitableFor: [],
|
|
whyThisLocation: 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');
|
|
});
|
|
});
|