feat: implement project development module, transfer management features, and industrial AVM model integration
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
/* eslint-disable import-x/order */
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
import type { ListingDetail } from '@/lib/listings-api';
|
||||
|
||||
// Mock the server-side listing fetch
|
||||
vi.mock('@/lib/listings-server', () => ({
|
||||
fetchListingById: vi.fn(),
|
||||
}));
|
||||
|
||||
// Avoid pulling in the heavy client component during unit tests
|
||||
vi.mock('@/components/listings/listing-detail-client', () => ({
|
||||
ListingDetailClient: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/seo/json-ld', () => ({
|
||||
JsonLd: () => null,
|
||||
generateBreadcrumbJsonLd: () => ({}),
|
||||
generateListingJsonLd: () => ({}),
|
||||
}));
|
||||
|
||||
import { fetchListingById } from '@/lib/listings-server';
|
||||
import { generateMetadata } from '../page';
|
||||
|
||||
const mockedFetch = vi.mocked(fetchListingById);
|
||||
|
||||
function buildListing(overrides: Partial<ListingDetail> = {}): ListingDetail {
|
||||
return {
|
||||
id: 'listing-1',
|
||||
status: 'APPROVED',
|
||||
transactionType: 'SALE',
|
||||
priceVND: '3500000000',
|
||||
pricePerM2: null,
|
||||
rentPriceMonthly: null,
|
||||
commissionPct: null,
|
||||
viewCount: 0,
|
||||
saveCount: 0,
|
||||
inquiryCount: 0,
|
||||
publishedAt: null,
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
property: {
|
||||
id: 'prop-1',
|
||||
propertyType: 'APARTMENT',
|
||||
title: 'Căn hộ cao cấp Quận 1',
|
||||
description: 'Đẹp, thoáng',
|
||||
address: '123 Lê Lợi',
|
||||
ward: 'Bến Nghé',
|
||||
district: 'Quận 1',
|
||||
city: 'Hồ Chí Minh',
|
||||
areaM2: 75,
|
||||
bedrooms: 2,
|
||||
bathrooms: 2,
|
||||
floors: 1,
|
||||
direction: null,
|
||||
yearBuilt: null,
|
||||
legalStatus: null,
|
||||
amenities: null,
|
||||
projectName: null,
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
media: [
|
||||
{
|
||||
id: 'img-1',
|
||||
url: 'https://cdn.example.com/img1.jpg',
|
||||
type: 'image',
|
||||
order: 0,
|
||||
caption: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
seller: { id: 'u-1', fullName: 'Nguyen Van A', phone: '0900000000' },
|
||||
agent: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('listing page generateMetadata', () => {
|
||||
beforeEach(() => {
|
||||
mockedFetch.mockReset();
|
||||
});
|
||||
|
||||
it('returns a not-found title when the listing is missing', async () => {
|
||||
mockedFetch.mockResolvedValueOnce(null);
|
||||
const meta = await generateMetadata({
|
||||
params: Promise.resolve({ locale: 'vi', id: 'missing' }),
|
||||
});
|
||||
expect(meta.title).toMatch(/Không tìm thấy/);
|
||||
});
|
||||
|
||||
it('builds OG + Twitter tags with image, canonical and alternates', async () => {
|
||||
mockedFetch.mockResolvedValueOnce(buildListing());
|
||||
const meta = await generateMetadata({
|
||||
params: Promise.resolve({ locale: 'vi', id: 'listing-1' }),
|
||||
});
|
||||
|
||||
expect(meta.title).toContain('Căn hộ cao cấp Quận 1');
|
||||
expect(String(meta.description)).toContain('75 m');
|
||||
expect(String(meta.description)).toContain('2 PN');
|
||||
expect(String(meta.description)).toContain('Quận 1');
|
||||
|
||||
expect(meta.alternates?.canonical).toMatch(/\/vi\/listings\/listing-1$/);
|
||||
expect(meta.alternates?.languages?.vi).toMatch(/\/vi\/listings\/listing-1$/);
|
||||
expect(meta.alternates?.languages?.en).toMatch(/\/en\/listings\/listing-1$/);
|
||||
|
||||
const og = meta.openGraph as Record<string, unknown>;
|
||||
expect(og.type).toBe('article');
|
||||
expect(og.locale).toBe('vi_VN');
|
||||
expect(og.siteName).toBe('GoodGo');
|
||||
const ogImages = og.images as Array<{ url: string; width: number; height: number }>;
|
||||
expect(ogImages[0]?.url).toBe('https://cdn.example.com/img1.jpg');
|
||||
expect(ogImages[0]?.width).toBe(1200);
|
||||
expect(ogImages[0]?.height).toBe(630);
|
||||
|
||||
const twitter = meta.twitter as Record<string, unknown>;
|
||||
expect(twitter.card).toBe('summary_large_image');
|
||||
expect((twitter.images as string[])[0]).toBe('https://cdn.example.com/img1.jpg');
|
||||
|
||||
expect(meta.other?.['og:price:currency']).toBe('VND');
|
||||
expect(meta.other?.['og:price:amount']).toBe('3500000000');
|
||||
});
|
||||
|
||||
it('falls back to default OG image when no media is present', async () => {
|
||||
mockedFetch.mockResolvedValueOnce(
|
||||
buildListing({
|
||||
property: { ...buildListing().property, media: [] },
|
||||
}),
|
||||
);
|
||||
const meta = await generateMetadata({
|
||||
params: Promise.resolve({ locale: 'en', id: 'listing-1' }),
|
||||
});
|
||||
|
||||
const og = meta.openGraph as Record<string, unknown>;
|
||||
expect(og.locale).toBe('en_US');
|
||||
const ogImages = og.images as Array<{ url: string }>;
|
||||
expect(ogImages[0]?.url).toBe('/og-image.png');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user