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

View File

@@ -7,6 +7,7 @@
"build": "next build",
"start": "next start",
"lint": "next lint",
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
"dependencies": {
@@ -34,6 +35,7 @@
"postcss": "^8.4.0",
"tailwindcss": "^3.4.0",
"tailwindcss-animate": "^1.0.7",
"typescript": "^6.0.2"
"typescript": "^6.0.2",
"vitest": "^4.1.3"
}
}

13
apps/web/vitest.config.ts Normal file
View File

@@ -0,0 +1,13 @@
import { defineConfig } from 'vitest/config';
import path from 'path';
export default defineConfig({
test: {
include: ['**/__tests__/**/*.spec.ts', '**/__tests__/**/*.test.ts'],
},
resolve: {
alias: {
'@': path.resolve(__dirname, '.'),
},
},
});

57
pnpm-lock.yaml generated
View File

@@ -314,6 +314,9 @@ importers:
typescript:
specifier: ^6.0.2
version: 6.0.2
vitest:
specifier: ^4.1.3
version: 4.1.3(@opentelemetry/api@1.9.1)(@types/node@25.5.2)(vite@7.3.2(@types/node@25.5.2)(jiti@1.21.7)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
libs/mcp-servers:
dependencies:
@@ -8660,7 +8663,7 @@ snapshots:
debug: 4.4.3
minimatch: 10.2.5
semver: 7.7.4
tinyglobby: 0.2.15
tinyglobby: 0.2.16
ts-api-utils: 2.5.0(typescript@6.0.2)
typescript: 6.0.2
transitivePeerDependencies:
@@ -8750,6 +8753,14 @@ snapshots:
chai: 6.2.2
tinyrainbow: 3.1.0
'@vitest/mocker@4.1.3(vite@7.3.2(@types/node@25.5.2)(jiti@1.21.7)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))':
dependencies:
'@vitest/spy': 4.1.3
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
vite: 7.3.2(@types/node@25.5.2)(jiti@1.21.7)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
'@vitest/mocker@4.1.3(vite@7.3.2(@types/node@25.5.2)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))':
dependencies:
'@vitest/spy': 4.1.3
@@ -11872,6 +11883,22 @@ snapshots:
d3-time: 3.1.0
d3-timer: 3.0.1
vite@7.3.2(@types/node@25.5.2)(jiti@1.21.7)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3):
dependencies:
esbuild: 0.27.7
fdir: 6.5.0(picomatch@4.0.4)
picomatch: 4.0.4
postcss: 8.5.9
rollup: 4.60.1
tinyglobby: 0.2.16
optionalDependencies:
'@types/node': 25.5.2
fsevents: 2.3.3
jiti: 1.21.7
terser: 5.46.1
tsx: 4.21.0
yaml: 2.8.3
vite@7.3.2(@types/node@25.5.2)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3):
dependencies:
esbuild: 0.27.7
@@ -11888,6 +11915,34 @@ snapshots:
tsx: 4.21.0
yaml: 2.8.3
vitest@4.1.3(@opentelemetry/api@1.9.1)(@types/node@25.5.2)(vite@7.3.2(@types/node@25.5.2)(jiti@1.21.7)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies:
'@vitest/expect': 4.1.3
'@vitest/mocker': 4.1.3(vite@7.3.2(@types/node@25.5.2)(jiti@1.21.7)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
'@vitest/pretty-format': 4.1.3
'@vitest/runner': 4.1.3
'@vitest/snapshot': 4.1.3
'@vitest/spy': 4.1.3
'@vitest/utils': 4.1.3
es-module-lexer: 2.0.0
expect-type: 1.3.0
magic-string: 0.30.21
obug: 2.1.1
pathe: 2.0.3
picomatch: 4.0.4
std-env: 4.0.0
tinybench: 2.9.0
tinyexec: 1.1.1
tinyglobby: 0.2.16
tinyrainbow: 3.1.0
vite: 7.3.2(@types/node@25.5.2)(jiti@1.21.7)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
why-is-node-running: 2.3.0
optionalDependencies:
'@opentelemetry/api': 1.9.1
'@types/node': 25.5.2
transitivePeerDependencies:
- msw
vitest@4.1.3(@opentelemetry/api@1.9.1)(@types/node@25.5.2)(vite@7.3.2(@types/node@25.5.2)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies:
'@vitest/expect': 4.1.3