- Remove unused imports (waitFor, useAuthStore) in dashboard test files - Convert import() type annotation to import type in comparison-store spec - Add next-env.d.ts to ESLint ignores (auto-generated file) - Fix empty object pattern in auth.fixture.ts - Sort import order alphabetically in 5 API test files Co-Authored-By: Paperclip <noreply@paperclip.ing>
271 lines
9.0 KiB
TypeScript
271 lines
9.0 KiB
TypeScript
/**
|
|
* @vitest-environment node
|
|
*/
|
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
import type { ListingDetail } from '../listings-api';
|
|
|
|
// Provide a minimal localStorage mock before importing the store module
|
|
// (Zustand persist needs getItem/setItem)
|
|
const store: Record<string, string> = {};
|
|
vi.stubGlobal('localStorage', {
|
|
getItem: (key: string) => store[key] ?? null,
|
|
setItem: (key: string, value: string) => { store[key] = value; },
|
|
removeItem: (key: string) => { delete store[key]; },
|
|
clear: () => { for (const k of Object.keys(store)) delete store[k]; },
|
|
get length() { return Object.keys(store).length; },
|
|
key: (i: number) => Object.keys(store)[i] ?? null,
|
|
});
|
|
|
|
// Now import after mocks are in place
|
|
const { useComparisonStore, computeComparisonStats, MAX_COMPARE, MIN_COMPARE } = await import('../comparison-store');
|
|
|
|
// Reset Zustand store between tests
|
|
beforeEach(() => {
|
|
useComparisonStore.setState({
|
|
selectedIds: [],
|
|
listings: [],
|
|
isLoading: false,
|
|
error: null,
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Store — addToCompare / removeFromCompare / isSelected
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('useComparisonStore', () => {
|
|
it('adds a listing ID to the comparison set', () => {
|
|
const { addToCompare } = useComparisonStore.getState();
|
|
const result = addToCompare('listing-1');
|
|
expect(result).toBe(true);
|
|
expect(useComparisonStore.getState().selectedIds).toEqual(['listing-1']);
|
|
});
|
|
|
|
it('does not add duplicate IDs', () => {
|
|
const { addToCompare } = useComparisonStore.getState();
|
|
addToCompare('listing-1');
|
|
const result = addToCompare('listing-1');
|
|
expect(result).toBe(false);
|
|
expect(useComparisonStore.getState().selectedIds).toEqual(['listing-1']);
|
|
});
|
|
|
|
it('respects MAX_COMPARE limit', () => {
|
|
const { addToCompare } = useComparisonStore.getState();
|
|
for (let i = 1; i <= MAX_COMPARE; i++) {
|
|
expect(addToCompare(`listing-${i}`)).toBe(true);
|
|
}
|
|
const result = addToCompare(`listing-${MAX_COMPARE + 1}`);
|
|
expect(result).toBe(false);
|
|
expect(useComparisonStore.getState().selectedIds.length).toBe(MAX_COMPARE);
|
|
});
|
|
|
|
it('removes a listing ID from the comparison set', () => {
|
|
const store = useComparisonStore.getState();
|
|
store.addToCompare('listing-1');
|
|
store.addToCompare('listing-2');
|
|
store.removeFromCompare('listing-1');
|
|
expect(useComparisonStore.getState().selectedIds).toEqual(['listing-2']);
|
|
});
|
|
|
|
it('isSelected returns correct value', () => {
|
|
const store = useComparisonStore.getState();
|
|
store.addToCompare('listing-1');
|
|
expect(store.isSelected('listing-1')).toBe(true);
|
|
expect(store.isSelected('listing-2')).toBe(false);
|
|
});
|
|
|
|
it('clearAll resets the store', () => {
|
|
const store = useComparisonStore.getState();
|
|
store.addToCompare('listing-1');
|
|
store.addToCompare('listing-2');
|
|
store.clearAll();
|
|
const state = useComparisonStore.getState();
|
|
expect(state.selectedIds).toEqual([]);
|
|
expect(state.listings).toEqual([]);
|
|
expect(state.error).toBeNull();
|
|
});
|
|
|
|
it('canCompare returns true when >= MIN_COMPARE items selected', () => {
|
|
const store = useComparisonStore.getState();
|
|
store.addToCompare('listing-1');
|
|
expect(useComparisonStore.getState().canCompare()).toBe(false);
|
|
store.addToCompare('listing-2');
|
|
expect(useComparisonStore.getState().canCompare()).toBe(true);
|
|
});
|
|
|
|
it('canAdd returns false when MAX_COMPARE items selected', () => {
|
|
const store = useComparisonStore.getState();
|
|
for (let i = 1; i <= MAX_COMPARE; i++) {
|
|
store.addToCompare(`listing-${i}`);
|
|
}
|
|
expect(useComparisonStore.getState().canAdd()).toBe(false);
|
|
});
|
|
|
|
it('setListings stores listing data and clears loading/error', () => {
|
|
const store = useComparisonStore.getState();
|
|
store.setLoading(true);
|
|
store.setError('some error');
|
|
const mockListings = [makeListing('1'), makeListing('2')];
|
|
store.setListings(mockListings);
|
|
const state = useComparisonStore.getState();
|
|
expect(state.listings).toEqual(mockListings);
|
|
expect(state.isLoading).toBe(false);
|
|
expect(state.error).toBeNull();
|
|
});
|
|
|
|
it('removeFromCompare also removes from listings', () => {
|
|
const store = useComparisonStore.getState();
|
|
store.addToCompare('listing-1');
|
|
store.addToCompare('listing-2');
|
|
store.setListings([makeListing('listing-1'), makeListing('listing-2')]);
|
|
store.removeFromCompare('listing-1');
|
|
const state = useComparisonStore.getState();
|
|
expect(state.selectedIds).toEqual(['listing-2']);
|
|
expect(state.listings.length).toBe(1);
|
|
expect(state.listings[0]?.id).toBe('listing-2');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// computeComparisonStats
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('computeComparisonStats', () => {
|
|
it('returns null for fewer than MIN_COMPARE listings', () => {
|
|
expect(computeComparisonStats([])).toBeNull();
|
|
expect(computeComparisonStats([makeListing('1')])).toBeNull();
|
|
});
|
|
|
|
it('computes correct price range', () => {
|
|
const listings = [
|
|
makeListing('1', { priceVND: '1000000000' }),
|
|
makeListing('2', { priceVND: '2000000000' }),
|
|
makeListing('3', { priceVND: '3000000000' }),
|
|
];
|
|
const stats = computeComparisonStats(listings);
|
|
expect(stats).not.toBeNull();
|
|
expect(stats!.priceRange.min).toBe(1_000_000_000);
|
|
expect(stats!.priceRange.max).toBe(3_000_000_000);
|
|
expect(stats!.priceRange.avg).toBe(2_000_000_000);
|
|
});
|
|
|
|
it('computes correct area range', () => {
|
|
const listings = [
|
|
makeListing('1', { areaM2: 50 }),
|
|
makeListing('2', { areaM2: 100 }),
|
|
];
|
|
const stats = computeComparisonStats(listings);
|
|
expect(stats).not.toBeNull();
|
|
expect(stats!.areaRange.min).toBe(50);
|
|
expect(stats!.areaRange.max).toBe(100);
|
|
expect(stats!.areaRange.avg).toBe(75);
|
|
});
|
|
|
|
it('computes pricePerM2Range when data is available', () => {
|
|
const listings = [
|
|
makeListing('1', { pricePerM2: 20_000_000 }),
|
|
makeListing('2', { pricePerM2: 40_000_000 }),
|
|
];
|
|
const stats = computeComparisonStats(listings);
|
|
expect(stats).not.toBeNull();
|
|
expect(stats!.pricePerM2Range).not.toBeNull();
|
|
expect(stats!.pricePerM2Range!.min).toBe(20_000_000);
|
|
expect(stats!.pricePerM2Range!.max).toBe(40_000_000);
|
|
expect(stats!.pricePerM2Range!.avg).toBe(30_000_000);
|
|
});
|
|
|
|
it('returns null pricePerM2Range when no pricePerM2 data', () => {
|
|
const listings = [
|
|
makeListing('1', { pricePerM2: null }),
|
|
makeListing('2', { pricePerM2: null }),
|
|
];
|
|
const stats = computeComparisonStats(listings);
|
|
expect(stats).not.toBeNull();
|
|
expect(stats!.pricePerM2Range).toBeNull();
|
|
});
|
|
|
|
it('handles mixed pricePerM2 availability', () => {
|
|
const listings = [
|
|
makeListing('1', { pricePerM2: 30_000_000 }),
|
|
makeListing('2', { pricePerM2: null }),
|
|
makeListing('3', { pricePerM2: 50_000_000 }),
|
|
];
|
|
const stats = computeComparisonStats(listings);
|
|
expect(stats).not.toBeNull();
|
|
expect(stats!.pricePerM2Range).not.toBeNull();
|
|
expect(stats!.pricePerM2Range!.min).toBe(30_000_000);
|
|
expect(stats!.pricePerM2Range!.max).toBe(50_000_000);
|
|
expect(stats!.pricePerM2Range!.avg).toBe(40_000_000);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Constants
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('constants', () => {
|
|
it('MAX_COMPARE is 5', () => {
|
|
expect(MAX_COMPARE).toBe(5);
|
|
});
|
|
|
|
it('MIN_COMPARE is 2', () => {
|
|
expect(MIN_COMPARE).toBe(2);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function makeListing(
|
|
id: string,
|
|
overrides: {
|
|
priceVND?: string;
|
|
areaM2?: number;
|
|
pricePerM2?: number | null;
|
|
} = {},
|
|
): ListingDetail {
|
|
return {
|
|
id,
|
|
status: 'ACTIVE',
|
|
transactionType: 'SALE',
|
|
priceVND: overrides.priceVND ?? '1500000000',
|
|
pricePerM2: overrides.pricePerM2 !== undefined ? overrides.pricePerM2 : 30_000_000,
|
|
rentPriceMonthly: null,
|
|
commissionPct: null,
|
|
viewCount: 0,
|
|
saveCount: 0,
|
|
inquiryCount: 0,
|
|
publishedAt: null,
|
|
createdAt: '2026-01-01T00:00:00Z',
|
|
property: {
|
|
id: `prop-${id}`,
|
|
propertyType: 'APARTMENT',
|
|
title: `Test Property ${id}`,
|
|
description: 'A test property',
|
|
address: '123 Test St',
|
|
ward: 'Ward 1',
|
|
district: 'District 1',
|
|
city: 'Ho Chi Minh',
|
|
areaM2: overrides.areaM2 ?? 80,
|
|
bedrooms: 2,
|
|
bathrooms: 2,
|
|
floors: null,
|
|
direction: null,
|
|
yearBuilt: null,
|
|
legalStatus: null,
|
|
amenities: [],
|
|
projectName: null,
|
|
latitude: null,
|
|
longitude: null,
|
|
media: [],
|
|
},
|
|
seller: {
|
|
id: 'seller-1',
|
|
fullName: 'Test Seller',
|
|
phone: '0912345678',
|
|
},
|
|
agent: null,
|
|
};
|
|
}
|