diff --git a/apps/web/lib/__tests__/auth-validations.spec.ts b/apps/web/lib/__tests__/auth-validations.spec.ts new file mode 100644 index 0000000..2a4c41b --- /dev/null +++ b/apps/web/lib/__tests__/auth-validations.spec.ts @@ -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); + }); +}); diff --git a/apps/web/lib/__tests__/listing-validations.spec.ts b/apps/web/lib/__tests__/listing-validations.spec.ts new file mode 100644 index 0000000..95ac23b --- /dev/null +++ b/apps/web/lib/__tests__/listing-validations.spec.ts @@ -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); + }); +}); diff --git a/apps/web/lib/__tests__/utils.spec.ts b/apps/web/lib/__tests__/utils.spec.ts new file mode 100644 index 0000000..d0b95b9 --- /dev/null +++ b/apps/web/lib/__tests__/utils.spec.ts @@ -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'); + }); +}); diff --git a/apps/web/package.json b/apps/web/package.json index 32457c1..d24ceed 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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" } } diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts new file mode 100644 index 0000000..4cf5a7d --- /dev/null +++ b/apps/web/vitest.config.ts @@ -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, '.'), + }, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c94a080..a228bf3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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