test(web): add Vitest setup and unit tests for validations and utils

- Add vitest config and test script to web app
- Auth validation tests: phone format, password rules, registration flow
- Listing validation tests: all schema steps, constants, merged schema
- Utils tests: cn() class merging with Tailwind conflict resolution
- 36 tests across 3 test files

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-08 14:59:00 +07:00
parent 6baa4707de
commit cd2abdba7b
6 changed files with 349 additions and 2 deletions

View File

@@ -0,0 +1,86 @@
import { describe, it, expect } from 'vitest';
import { loginSchema, registerSchema } from '../validations/auth';
describe('loginSchema', () => {
it('should accept valid Vietnamese phone and password', () => {
const result = loginSchema.safeParse({ phone: '0912345678', password: 'abc123' });
expect(result.success).toBe(true);
});
it('should accept +84 format phone', () => {
const result = loginSchema.safeParse({ phone: '+84912345678', password: 'abc123' });
expect(result.success).toBe(true);
});
it('should reject empty phone', () => {
const result = loginSchema.safeParse({ phone: '', password: 'abc123' });
expect(result.success).toBe(false);
});
it('should reject invalid phone format', () => {
const result = loginSchema.safeParse({ phone: '12345', password: 'abc123' });
expect(result.success).toBe(false);
});
it('should reject empty password', () => {
const result = loginSchema.safeParse({ phone: '0912345678', password: '' });
expect(result.success).toBe(false);
});
it('should accept various valid Vietnamese prefixes', () => {
const prefixes = ['032', '056', '070', '081', '090'];
for (const prefix of prefixes) {
const result = loginSchema.safeParse({ phone: `${prefix}1234567`, password: 'pass' });
expect(result.success).toBe(true);
}
});
});
describe('registerSchema', () => {
const validData = {
fullName: 'Nguyen Van A',
phone: '0912345678',
password: '12345678',
confirmPassword: '12345678',
};
it('should accept valid registration data', () => {
const result = registerSchema.safeParse(validData);
expect(result.success).toBe(true);
});
it('should accept with optional email', () => {
const result = registerSchema.safeParse({ ...validData, email: 'test@example.com' });
expect(result.success).toBe(true);
});
it('should accept empty string email', () => {
const result = registerSchema.safeParse({ ...validData, email: '' });
expect(result.success).toBe(true);
});
it('should reject invalid email', () => {
const result = registerSchema.safeParse({ ...validData, email: 'not-email' });
expect(result.success).toBe(false);
});
it('should reject short password', () => {
const result = registerSchema.safeParse({ ...validData, password: '123', confirmPassword: '123' });
expect(result.success).toBe(false);
});
it('should reject mismatched passwords', () => {
const result = registerSchema.safeParse({ ...validData, confirmPassword: 'different' });
expect(result.success).toBe(false);
});
it('should reject short full name', () => {
const result = registerSchema.safeParse({ ...validData, fullName: 'A' });
expect(result.success).toBe(false);
});
it('should reject empty full name', () => {
const result = registerSchema.safeParse({ ...validData, fullName: '' });
expect(result.success).toBe(false);
});
});

View File

@@ -0,0 +1,165 @@
import { describe, it, expect } from 'vitest';
import {
listingBasicSchema,
listingLocationSchema,
listingDetailsSchema,
listingPricingSchema,
createListingSchema,
TRANSACTION_TYPES,
PROPERTY_TYPES,
DIRECTIONS,
LISTING_STATUSES,
} from '../validations/listings';
describe('listingBasicSchema', () => {
it('should accept valid basic info', () => {
const result = listingBasicSchema.safeParse({
transactionType: 'SALE',
propertyType: 'APARTMENT',
title: 'Căn hộ đẹp',
description: 'Mô tả chi tiết về căn hộ',
});
expect(result.success).toBe(true);
});
it('should reject invalid transaction type', () => {
const result = listingBasicSchema.safeParse({
transactionType: 'INVALID',
propertyType: 'APARTMENT',
title: 'Valid title',
description: 'Valid description',
});
expect(result.success).toBe(false);
});
it('should reject short title', () => {
const result = listingBasicSchema.safeParse({
transactionType: 'SALE',
propertyType: 'HOUSE',
title: 'Ab',
description: 'Valid description',
});
expect(result.success).toBe(false);
});
it('should reject short description', () => {
const result = listingBasicSchema.safeParse({
transactionType: 'RENT',
propertyType: 'VILLA',
title: 'Valid title',
description: 'Short',
});
expect(result.success).toBe(false);
});
it('should accept all valid property types', () => {
for (const pt of PROPERTY_TYPES) {
const result = listingBasicSchema.safeParse({
transactionType: 'SALE',
propertyType: pt.value,
title: 'Valid title here',
description: 'Valid description here',
});
expect(result.success).toBe(true);
}
});
});
describe('listingLocationSchema', () => {
it('should accept valid location', () => {
const result = listingLocationSchema.safeParse({
address: '123 Đường ABC',
ward: 'Phường 1',
district: 'Quận 1',
city: 'TP. Hồ Chí Minh',
});
expect(result.success).toBe(true);
});
it('should reject empty address', () => {
const result = listingLocationSchema.safeParse({
address: '',
ward: 'Phường 1',
district: 'Quận 1',
city: 'TP. HCM',
});
expect(result.success).toBe(false);
});
it('should accept optional lat/lng', () => {
const result = listingLocationSchema.safeParse({
address: '123 Đường ABC',
ward: 'Phường 1',
district: 'Quận 1',
city: 'Hà Nội',
latitude: '21.0285',
longitude: '105.8542',
});
expect(result.success).toBe(true);
});
});
describe('listingPricingSchema', () => {
it('should accept valid price', () => {
const result = listingPricingSchema.safeParse({ priceVND: '5000000000' });
expect(result.success).toBe(true);
});
it('should reject empty price', () => {
const result = listingPricingSchema.safeParse({ priceVND: '' });
expect(result.success).toBe(false);
});
it('should accept optional rent and commission', () => {
const result = listingPricingSchema.safeParse({
priceVND: '15000000',
rentPriceMonthly: '15000000',
commissionPct: '3',
});
expect(result.success).toBe(true);
});
});
describe('createListingSchema (merged)', () => {
const validListing = {
transactionType: 'SALE' as const,
propertyType: 'APARTMENT' as const,
title: 'Căn hộ 2PN Quận 7',
description: 'Căn hộ view sông, full nội thất',
address: '123 Nguyễn Hữu Thọ',
ward: 'Phường Tân Hưng',
district: 'Quận 7',
city: 'TP. Hồ Chí Minh',
areaM2: '75',
priceVND: '3500000000',
};
it('should accept a complete valid listing', () => {
const result = createListingSchema.safeParse(validListing);
expect(result.success).toBe(true);
});
it('should reject if basic fields missing', () => {
const { title: _, ...noTitle } = validListing;
const result = createListingSchema.safeParse(noTitle);
expect(result.success).toBe(false);
});
});
describe('constants', () => {
it('should have 2 transaction types', () => {
expect(TRANSACTION_TYPES).toHaveLength(2);
});
it('should have 6 property types', () => {
expect(PROPERTY_TYPES).toHaveLength(6);
});
it('should have 8 directions', () => {
expect(DIRECTIONS).toHaveLength(8);
});
it('should have 8 listing statuses', () => {
expect(Object.keys(LISTING_STATUSES)).toHaveLength(8);
});
});

View File

@@ -0,0 +1,26 @@
import { describe, it, expect } from 'vitest';
import { cn } from '../utils';
describe('cn', () => {
it('should merge class names', () => {
expect(cn('foo', 'bar')).toBe('foo bar');
});
it('should handle conditional classes', () => {
expect(cn('base', false && 'hidden', 'visible')).toBe('base visible');
});
it('should merge conflicting Tailwind classes', () => {
expect(cn('px-4', 'px-6')).toBe('px-6');
expect(cn('text-red-500', 'text-blue-500')).toBe('text-blue-500');
});
it('should handle empty inputs', () => {
expect(cn()).toBe('');
expect(cn('')).toBe('');
});
it('should handle arrays', () => {
expect(cn(['foo', 'bar'])).toBe('foo bar');
});
});