- 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>
253 lines
9.6 KiB
TypeScript
253 lines
9.6 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',
|
|
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');
|
|
});
|
|
});
|