feat: implement project development module, transfer management features, and industrial AVM model integration

This commit is contained in:
Ho Ngoc Hai
2026-04-18 20:34:35 +07:00
parent 0f3b4d7b0d
commit 38b9def99a
66 changed files with 9051 additions and 17 deletions

View File

@@ -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');
});
});