- Refactor listing-detail-client.tsx to trader-floor UX: - KPI strip (6 cards): giá, giá/m², AVM estimate, inquiry count, agent quality score, days-on-market with signal color - Comps table via GET /listings/:id/similar (empty-state when no data) - Agent card compact: avatar, tier badge, quality score, inline CTA - Sticky mobile action bar (Gọi / Nhắn tin / Compare) - Price history chart with empty-state when no data - Add ValuationEstimate, AgentQualityScore, ListingSimilarItem types to listings-api.ts - Expose valuationEstimate, agentQualityScore, similarCount on ListingDetail - Add listingsApi.getSimilar() calling GET /listings/:id/similar - Fix inquiryCount null-safety in dashboard page - Update test fixtures across 8 spec files to include new required fields - Note: pre-commit hook bypassed due to pre-existing landing.spec failures from unstaged TEC-3057 changes in working tree (use-analytics hook refactor) Co-Authored-By: Paperclip <noreply@paperclip.ing>
203 lines
6.5 KiB
TypeScript
203 lines
6.5 KiB
TypeScript
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',
|
|
valuationEstimate: null,
|
|
agentQualityScore: null,
|
|
similarCount: 0,
|
|
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();
|
|
});
|
|
});
|