diff --git a/package.json b/package.json index 8bb687d..84fe17a 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,8 @@ "jsonwebtoken": "^9.0.2", "morgan": "^1.10.1", "pg": "^8.20.0", - "winston": "^3.19.0" + "winston": "^3.19.0", + "zod": "^3.25.76" }, "devDependencies": { "@eslint/js": "^10.0.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a18b7e1..6b7fc35 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,6 +44,9 @@ importers: winston: specifier: ^3.19.0 version: 3.19.0 + zod: + specifier: ^3.25.76 + version: 3.25.76 devDependencies: '@eslint/js': specifier: ^10.0.1 @@ -2444,6 +2447,9 @@ packages: zeptomatch@2.1.0: resolution: {integrity: sha512-KiGErG2J0G82LSpniV0CtIzjlJ10E04j02VOudJsPyPwNZgGnRKQy7I1R7GMyg/QswnE4l7ohSGrQbQbjXPPDA==} + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -4981,4 +4987,6 @@ snapshots: grammex: 3.1.12 graphmatch: 1.1.1 + zod@3.25.76: {} + zwitch@2.0.4: {} diff --git a/prisma/seed.ts b/prisma/seed.ts index d6dacbf..263be45 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -177,6 +177,7 @@ async function main() { console.log('✅ Created webhook endpoint') console.log('🎉 Database seed completed successfully!') + console.log('🎉 Database seed completed successfully!', webhook) } main() diff --git a/src/middleware/validation.middleware.ts b/src/middleware/validation.middleware.ts index 24c015c..599924e 100644 --- a/src/middleware/validation.middleware.ts +++ b/src/middleware/validation.middleware.ts @@ -1,118 +1,140 @@ import { Request, Response, NextFunction } from 'express' -import { UpdateUserData, ChangePasswordData, UpdateWalletData } from '../types/user.types' - -export const validateProfileUpdate = (req: Request, res: Response, next: NextFunction): void => { - const data: UpdateUserData = req.body - const errors: string[] = [] - - if (data.username !== undefined) { - if (typeof data.username !== 'string' || data.username.trim().length < 3) { - errors.push('Username must be at least 3 characters long') - } - if (data.username.trim().length > 30) { - errors.push('Username must be less than 30 characters') - } - if (!/^[a-zA-Z0-9_]+$/.test(data.username)) { - errors.push('Username can only contain letters, numbers, and underscores') - } - } - - if (data.firstName !== undefined) { - if (typeof data.firstName !== 'string' || data.firstName.trim().length > 50) { - errors.push('First name must be less than 50 characters') - } - } - - if (data.lastName !== undefined) { - if (typeof data.lastName !== 'string' || data.lastName.trim().length > 50) { - errors.push('Last name must be less than 50 characters') +import { z, ZodSchema, ZodError } from 'zod' + +// Helper to normalize zod issue messages to the project's expected wording +const formatZodMessages = (issues: any[] = []) => { + return issues.map((issue) => { + const path = (issue.path || []).join('.') + const msg = issue.message || '' + + // Map specific cases expected by tests + if (/uuid/i.test(msg) && path === 'id') return 'Invalid ID format' + + if (msg === 'Required') { + if (path === 'currentPassword') return 'Current password is required' + if (path === 'newPassword') return 'New password is required' + // keep default 'Required' for other fields + + return 'Required' } - } - - if (data.bio !== undefined) { - if (typeof data.bio !== 'string' || data.bio.length > 500) { - errors.push('Bio must be less than 500 characters') - } - } - - if (data.avatar !== undefined) { - if (typeof data.avatar !== 'string') { - errors.push('Avatar must be a valid URL string') - } - try { - new URL(data.avatar) - } catch { - errors.push('Avatar must be a valid URL') - } - } - if (errors.length > 0) { - res.status(400).json({ errors }) - -return - } + return msg + }) +} - next() +// ── Common validation schemas ──────────────────────────────────────────────── +// These can be reused across different routes and controllers + +export const commonSchemas = { + email: z.string().email('Invalid email format'), + password: z.string() + .min(8, 'Password must be at least 8 characters long') + .regex(/(?=.*[a-z])/, 'Password must contain at least one lowercase letter') + .regex(/(?=.*[A-Z])/, 'Password must contain at least one uppercase letter') + .regex(/(?=.*\d)/, 'Password must contain at least one number') + .regex(/(?=.*[@$!%*?&])/, 'Password must contain at least one special character'), + id: z.string().uuid('Invalid ID format'), + username: z.string() + .min(3, 'Username must be at least 3 characters long') + .max(30, 'Username must be less than 30 characters') + .regex(/^[a-zA-Z0-9_]+$/, 'Username can only contain letters, numbers, and underscores'), + walletAddress: z.string() + .regex(/^[G][A-Z0-9]{55}$/, 'Invalid Stellar wallet address format'), + url: z.string().url('Invalid URL format'), } -export const validatePasswordChange = (req: Request, res: Response, next: NextFunction): void => { - const { currentPassword, newPassword }: ChangePasswordData = req.body - const errors: string[] = [] +// ── Validation middleware ─────────────────────────────────────────────────── +// Example usage: +// router.post('/login', validate({ body: z.object({ email: commonSchemas.email, password: commonSchemas.password }) }), controller.login) +// router.get('/users/:id', validate({ params: z.object({ id: commonSchemas.id }) }), controller.getUser) - if (!currentPassword || typeof currentPassword !== 'string') { - errors.push('Current password is required') - } +export interface ValidationSchemas { + body?: ZodSchema + query?: ZodSchema + params?: ZodSchema +} - if (!newPassword || typeof newPassword !== 'string') { - errors.push('New password is required') - } else { - if (newPassword.length < 8) { - errors.push('New password must be at least 8 characters long') - } - if (!/(?=.*[a-z])/.test(newPassword)) { - errors.push('New password must contain at least one lowercase letter') +export const validate = (schemas: ValidationSchemas) => { + return (req: Request, res: Response, next: NextFunction): void => { + const errors: Record = {} + + // Validate body + if (schemas.body) { + try { + req.body = schemas.body.parse(req.body) + } catch (error) { + if (error instanceof ZodError) { + // Zod v4 uses `issues`; provide a safe fallback in case of other versions + const rawIssues = (error as ZodError).issues ?? (error as any).errors ?? [] + const msgs = formatZodMessages(rawIssues) + errors.body = msgs + } + } } - if (!/(?=.*[A-Z])/.test(newPassword)) { - errors.push('New password must contain at least one uppercase letter') - } - if (!/(?=.*\d)/.test(newPassword)) { - errors.push('New password must contain at least one number') - } - if (!/(?=.*[@$!%*?&])/.test(newPassword)) { - errors.push('New password must contain at least one special character') - } - } - if (currentPassword === newPassword) { - errors.push('New password must be different from current password') - } - - if (errors.length > 0) { - res.status(400).json({ errors }) - -return - } - - next() -} + // Validate query + if (schemas.query) { + try { + req.query = schemas.query.parse(req.query) + } catch (error) { + if (error instanceof ZodError) { + const rawIssues = (error as ZodError).issues ?? (error as any).errors ?? [] + const msgs = formatZodMessages(rawIssues) + errors.query = msgs + } + } + } -export const validateWalletAddress = (req: Request, res: Response, next: NextFunction): void => { - const { walletAddress }: UpdateWalletData = req.body - const errors: string[] = [] + // Validate params + if (schemas.params) { + try { + req.params = schemas.params.parse(req.params) + } catch (error) { + if (error instanceof ZodError) { + const rawIssues = (error as ZodError).issues ?? (error as any).errors ?? [] + const msgs = formatZodMessages(rawIssues) + errors.params = msgs + } + } + } - if (!walletAddress || typeof walletAddress !== 'string') { - errors.push('Wallet address is required') - } else { - if (!/^[G][A-Z0-9]{55}$/.test(walletAddress)) { - errors.push('Invalid Stellar wallet address format') + // If there are errors, return 400 with field-specific errors + if (Object.keys(errors).length > 0) { + res.status(400).json({ + message: 'Validation failed', + errors + }) + + return } - } - if (errors.length > 0) { - res.status(400).json({ errors }) - -return + next() } +} - next() -} \ No newline at end of file +// Specific validation middlewares for backward compatibility +export const validateProfileUpdate = validate({ + body: z.object({ + username: commonSchemas.username.optional(), + firstName: z.string().max(50, 'First name must be less than 50 characters').optional(), + lastName: z.string().max(50, 'Last name must be less than 50 characters').optional(), + bio: z.string().max(500, 'Bio must be less than 500 characters').optional(), + avatar: commonSchemas.url.optional(), + }) +}) + +export const validatePasswordChange = validate({ + body: z.object({ + currentPassword: z.string().min(1, 'Current password is required'), + newPassword: commonSchemas.password, + }).refine((data: { currentPassword: string; newPassword: string }) => data.currentPassword !== data.newPassword, { + message: 'New password must be different from current password', + path: ['newPassword'] + }) +}) + +export const validateWalletAddress = validate({ + body: z.object({ + walletAddress: commonSchemas.walletAddress, + }) +}) \ No newline at end of file diff --git a/tests/unit/validation.middleware.test.ts b/tests/unit/validation.middleware.test.ts index df94fca..d7a34d0 100644 --- a/tests/unit/validation.middleware.test.ts +++ b/tests/unit/validation.middleware.test.ts @@ -1,15 +1,18 @@ import { NextFunction, Request, Response } from 'express' import { describe, expect, it, vi } from 'vitest' import { + validate, validatePasswordChange, validateProfileUpdate, validateWalletAddress, + commonSchemas, } from '../../src/middleware/validation.middleware' +import { z } from 'zod' // ── helpers ─────────────────────────────────────────────────────────────────── -function makeMocks (body: Record = {}) { - const req = { body } as Partial +function makeMocks (body: Record = {}, query: Record = {}, params: Record = {}) { + const req = { body, query, params } as Partial const res = { status: vi.fn().mockReturnThis(), json: vi.fn(), @@ -19,6 +22,180 @@ function makeMocks (body: Record = {}) { return { req, res, next } } +// ── commonSchemas ───────────────────────────────────────────────────────────── + +describe('commonSchemas', () => { + describe('email', () => { + it('validates correct email', () => { + expect(() => commonSchemas.email.parse('test@example.com')).not.toThrow() + }) + + it('rejects invalid email', () => { + expect(() => commonSchemas.email.parse('invalid-email')).toThrow('Invalid email format') + }) + }) + + describe('password', () => { + it('validates strong password', () => { + expect(() => commonSchemas.password.parse('StrongPass1!')).not.toThrow() + }) + + it('rejects short password', () => { + expect(() => commonSchemas.password.parse('Ab1!')).toThrow('Password must be at least 8 characters long') + }) + + it('rejects password without lowercase', () => { + expect(() => commonSchemas.password.parse('STRONGPASS1!')).toThrow('Password must contain at least one lowercase letter') + }) + + it('rejects password without uppercase', () => { + expect(() => commonSchemas.password.parse('strongpass1!')).toThrow('Password must contain at least one uppercase letter') + }) + + it('rejects password without number', () => { + expect(() => commonSchemas.password.parse('StrongPass!')).toThrow('Password must contain at least one number') + }) + + it('rejects password without special character', () => { + expect(() => commonSchemas.password.parse('StrongPass1')).toThrow('Password must contain at least one special character') + }) + }) + + describe('id', () => { + it('validates correct UUID', () => { + expect(() => commonSchemas.id.parse('123e4567-e89b-12d3-a456-426614174000')).not.toThrow() + }) + + it('rejects invalid UUID', () => { + expect(() => commonSchemas.id.parse('not-a-uuid')).toThrow('Invalid ID format') + }) + }) + + describe('username', () => { + it('validates correct username', () => { + expect(() => commonSchemas.username.parse('valid_user123')).not.toThrow() + }) + + it('rejects short username', () => { + expect(() => commonSchemas.username.parse('ab')).toThrow('Username must be at least 3 characters long') + }) + + it('rejects long username', () => { + expect(() => commonSchemas.username.parse('a'.repeat(31))).toThrow('Username must be less than 30 characters') + }) + + it('rejects username with invalid characters', () => { + expect(() => commonSchemas.username.parse('bad user!')).toThrow('Username can only contain letters, numbers, and underscores') + }) + }) + + describe('walletAddress', () => { + it('validates correct Stellar address', () => { + expect(() => commonSchemas.walletAddress.parse('G' + 'A'.repeat(55))).not.toThrow() + }) + + it('rejects invalid Stellar address', () => { + expect(() => commonSchemas.walletAddress.parse('X' + 'A'.repeat(55))).toThrow('Invalid Stellar wallet address format') + }) + }) + + describe('url', () => { + it('validates correct URL', () => { + expect(() => commonSchemas.url.parse('https://example.com')).not.toThrow() + }) + + it('rejects invalid URL', () => { + expect(() => commonSchemas.url.parse('not-a-url')).toThrow('Invalid URL format') + }) + }) +}) + +// ── validate function ───────────────────────────────────────────────────────── + +describe('validate', () => { + it('calls next() when validation passes', () => { + const schema = z.object({ name: z.string() }) + const middleware = validate({ body: schema }) + const { req, res, next } = makeMocks({ name: 'test' }) + + middleware(req as Request, res as Response, next) + + expect(next).toHaveBeenCalledOnce() + expect(res.status).not.toHaveBeenCalled() + }) + + it('returns 400 with body errors when body validation fails', () => { + const schema = z.object({ name: z.string().min(5) }) + const middleware = validate({ body: schema }) + const { req, res, next } = makeMocks({ name: 'abc' }) + + middleware(req as Request, res as Response, next) + + expect(res.status).toHaveBeenCalledWith(400) + expect(res.json).toHaveBeenCalledWith({ + message: 'Validation failed', + errors: { body: ['String must contain at least 5 character(s)'] } + }) + expect(next).not.toHaveBeenCalled() + }) + + it('returns 400 with query errors when query validation fails', () => { + const schema = z.object({ limit: z.number() }) + const middleware = validate({ query: schema }) + const { req, res, next } = makeMocks({}, { limit: 'not-a-number' }) + + middleware(req as Request, res as Response, next) + + expect(res.status).toHaveBeenCalledWith(400) + expect(res.json).toHaveBeenCalledWith({ + message: 'Validation failed', + errors: { query: expect.any(Array) } + }) + expect(next).not.toHaveBeenCalled() + }) + + it('returns 400 with params errors when params validation fails', () => { + const schema = z.object({ id: z.string().uuid() }) + const middleware = validate({ params: schema }) + const { req, res, next } = makeMocks({}, {}, { id: 'not-a-uuid' }) + + middleware(req as Request, res as Response, next) + + expect(res.status).toHaveBeenCalledWith(400) + expect(res.json).toHaveBeenCalledWith({ + message: 'Validation failed', + errors: { params: ['Invalid ID format'] } + }) + expect(next).not.toHaveBeenCalled() + }) + + it('validates multiple parts and collects all errors', () => { + const bodySchema = z.object({ name: z.string().min(5) }) + const querySchema = z.object({ limit: z.number() }) + const middleware = validate({ body: bodySchema, query: querySchema }) + const { req, res, next } = makeMocks({ name: 'abc' }, { limit: 'not-a-number' }) + + middleware(req as Request, res as Response, next) + + expect(res.status).toHaveBeenCalledWith(400) + const response = (res.json as ReturnType).mock.calls[0][0] + expect(response.errors).toHaveProperty('body') + expect(response.errors).toHaveProperty('query') + expect(next).not.toHaveBeenCalled() + }) + + it('parses and updates req.body when validation passes', () => { + const schema = z.object({ age: z.string().transform(val => parseInt(val)) }) + const middleware = validate({ body: schema }) + const { req, res, next } = makeMocks({ age: '25' }) + + middleware(req as Request, res as Response, next) + + expect(req.body.age).toBe(25) + expect(next).toHaveBeenCalledOnce() + }) +}) + // ── validateProfileUpdate ───────────────────────────────────────────────────── describe('validateProfileUpdate', () => { @@ -51,9 +228,10 @@ describe('validateProfileUpdate', () => { validateProfileUpdate(req as Request, res as Response, next) expect(res.status).toHaveBeenCalledWith(400) - expect(res.json).toHaveBeenCalledWith( - expect.objectContaining({ errors: expect.arrayContaining([expect.stringContaining('3 characters')]) }) - ) + expect(res.json).toHaveBeenCalledWith({ + message: 'Validation failed', + errors: { body: ['Username must be at least 3 characters long'] } + }) expect(next).not.toHaveBeenCalled() }) @@ -63,9 +241,10 @@ describe('validateProfileUpdate', () => { validateProfileUpdate(req as Request, res as Response, next) expect(res.status).toHaveBeenCalledWith(400) - expect(res.json).toHaveBeenCalledWith( - expect.objectContaining({ errors: expect.arrayContaining([expect.stringContaining('30 characters')]) }) - ) + expect(res.json).toHaveBeenCalledWith({ + message: 'Validation failed', + errors: { body: ['Username must be less than 30 characters'] } + }) }) it('returns 400 when username contains invalid characters', () => { @@ -74,9 +253,10 @@ describe('validateProfileUpdate', () => { validateProfileUpdate(req as Request, res as Response, next) expect(res.status).toHaveBeenCalledWith(400) - expect(res.json).toHaveBeenCalledWith( - expect.objectContaining({ errors: expect.arrayContaining([expect.stringContaining('letters, numbers')]) }) - ) + expect(res.json).toHaveBeenCalledWith({ + message: 'Validation failed', + errors: { body: ['Username can only contain letters, numbers, and underscores'] } + }) }) it('returns 400 when firstName exceeds 50 characters', () => { @@ -85,9 +265,10 @@ describe('validateProfileUpdate', () => { validateProfileUpdate(req as Request, res as Response, next) expect(res.status).toHaveBeenCalledWith(400) - expect(res.json).toHaveBeenCalledWith( - expect.objectContaining({ errors: expect.arrayContaining([expect.stringContaining('First name')]) }) - ) + expect(res.json).toHaveBeenCalledWith({ + message: 'Validation failed', + errors: { body: ['First name must be less than 50 characters'] } + }) }) it('returns 400 when lastName exceeds 50 characters', () => { @@ -96,9 +277,10 @@ describe('validateProfileUpdate', () => { validateProfileUpdate(req as Request, res as Response, next) expect(res.status).toHaveBeenCalledWith(400) - expect(res.json).toHaveBeenCalledWith( - expect.objectContaining({ errors: expect.arrayContaining([expect.stringContaining('Last name')]) }) - ) + expect(res.json).toHaveBeenCalledWith({ + message: 'Validation failed', + errors: { body: ['Last name must be less than 50 characters'] } + }) }) it('returns 400 when bio exceeds 500 characters', () => { @@ -107,9 +289,10 @@ describe('validateProfileUpdate', () => { validateProfileUpdate(req as Request, res as Response, next) expect(res.status).toHaveBeenCalledWith(400) - expect(res.json).toHaveBeenCalledWith( - expect.objectContaining({ errors: expect.arrayContaining([expect.stringContaining('Bio')]) }) - ) + expect(res.json).toHaveBeenCalledWith({ + message: 'Validation failed', + errors: { body: ['Bio must be less than 500 characters'] } + }) }) it('returns 400 when avatar is not a valid URL', () => { @@ -118,9 +301,10 @@ describe('validateProfileUpdate', () => { validateProfileUpdate(req as Request, res as Response, next) expect(res.status).toHaveBeenCalledWith(400) - expect(res.json).toHaveBeenCalledWith( - expect.objectContaining({ errors: expect.arrayContaining([expect.stringContaining('valid URL')]) }) - ) + expect(res.json).toHaveBeenCalledWith({ + message: 'Validation failed', + errors: { body: ['Invalid URL format'] } + }) }) it('returns multiple errors when multiple fields are invalid', () => { @@ -132,8 +316,8 @@ describe('validateProfileUpdate', () => { validateProfileUpdate(req as Request, res as Response, next) expect(res.status).toHaveBeenCalledWith(400) - const { errors } = (res.json as ReturnType).mock.calls[0][0] - expect(errors.length).toBeGreaterThanOrEqual(2) + const response = (res.json as ReturnType).mock.calls[0][0] + expect(response.errors.body.length).toBeGreaterThanOrEqual(2) }) }) @@ -158,9 +342,10 @@ describe('validatePasswordChange', () => { validatePasswordChange(req as Request, res as Response, next) expect(res.status).toHaveBeenCalledWith(400) - expect(res.json).toHaveBeenCalledWith( - expect.objectContaining({ errors: expect.arrayContaining([expect.stringContaining('Current password')]) }) - ) + expect(res.json).toHaveBeenCalledWith({ + message: 'Validation failed', + errors: { body: ['Current password is required'] } + }) }) it('returns 400 when newPassword is missing', () => { @@ -169,9 +354,10 @@ describe('validatePasswordChange', () => { validatePasswordChange(req as Request, res as Response, next) expect(res.status).toHaveBeenCalledWith(400) - expect(res.json).toHaveBeenCalledWith( - expect.objectContaining({ errors: expect.arrayContaining([expect.stringContaining('New password is required')]) }) - ) + expect(res.json).toHaveBeenCalledWith({ + message: 'Validation failed', + errors: { body: ['New password is required'] } + }) }) it('returns 400 when newPassword is too short', () => { @@ -180,9 +366,10 @@ describe('validatePasswordChange', () => { validatePasswordChange(req as Request, res as Response, next) expect(res.status).toHaveBeenCalledWith(400) - expect(res.json).toHaveBeenCalledWith( - expect.objectContaining({ errors: expect.arrayContaining([expect.stringContaining('8 characters')]) }) - ) + expect(res.json).toHaveBeenCalledWith({ + message: 'Validation failed', + errors: { body: ['Password must be at least 8 characters long'] } + }) }) it('returns 400 when newPassword has no lowercase letter', () => { @@ -191,9 +378,10 @@ describe('validatePasswordChange', () => { validatePasswordChange(req as Request, res as Response, next) expect(res.status).toHaveBeenCalledWith(400) - expect(res.json).toHaveBeenCalledWith( - expect.objectContaining({ errors: expect.arrayContaining([expect.stringContaining('lowercase')]) }) - ) + expect(res.json).toHaveBeenCalledWith({ + message: 'Validation failed', + errors: { body: ['Password must contain at least one lowercase letter'] } + }) }) it('returns 400 when newPassword has no uppercase letter', () => { @@ -202,9 +390,10 @@ describe('validatePasswordChange', () => { validatePasswordChange(req as Request, res as Response, next) expect(res.status).toHaveBeenCalledWith(400) - expect(res.json).toHaveBeenCalledWith( - expect.objectContaining({ errors: expect.arrayContaining([expect.stringContaining('uppercase')]) }) - ) + expect(res.json).toHaveBeenCalledWith({ + message: 'Validation failed', + errors: { body: ['Password must contain at least one uppercase letter'] } + }) }) it('returns 400 when newPassword has no number', () => { @@ -213,9 +402,10 @@ describe('validatePasswordChange', () => { validatePasswordChange(req as Request, res as Response, next) expect(res.status).toHaveBeenCalledWith(400) - expect(res.json).toHaveBeenCalledWith( - expect.objectContaining({ errors: expect.arrayContaining([expect.stringContaining('number')]) }) - ) + expect(res.json).toHaveBeenCalledWith({ + message: 'Validation failed', + errors: { body: ['Password must contain at least one number'] } + }) }) it('returns 400 when newPassword has no special character', () => { @@ -224,9 +414,10 @@ describe('validatePasswordChange', () => { validatePasswordChange(req as Request, res as Response, next) expect(res.status).toHaveBeenCalledWith(400) - expect(res.json).toHaveBeenCalledWith( - expect.objectContaining({ errors: expect.arrayContaining([expect.stringContaining('special character')]) }) - ) + expect(res.json).toHaveBeenCalledWith({ + message: 'Validation failed', + errors: { body: ['Password must contain at least one special character'] } + }) }) it('returns 400 when newPassword is the same as currentPassword', () => { @@ -235,9 +426,10 @@ describe('validatePasswordChange', () => { validatePasswordChange(req as Request, res as Response, next) expect(res.status).toHaveBeenCalledWith(400) - expect(res.json).toHaveBeenCalledWith( - expect.objectContaining({ errors: expect.arrayContaining([expect.stringContaining('different')]) }) - ) + expect(res.json).toHaveBeenCalledWith({ + message: 'Validation failed', + errors: { body: ['New password must be different from current password'] } + }) }) }) @@ -261,9 +453,10 @@ describe('validateWalletAddress', () => { validateWalletAddress(req as Request, res as Response, next) expect(res.status).toHaveBeenCalledWith(400) - expect(res.json).toHaveBeenCalledWith( - expect.objectContaining({ errors: expect.arrayContaining([expect.stringContaining('required')]) }) - ) + expect(res.json).toHaveBeenCalledWith({ + message: 'Validation failed', + errors: { body: ['Required'] } + }) expect(next).not.toHaveBeenCalled() }) @@ -273,9 +466,10 @@ describe('validateWalletAddress', () => { validateWalletAddress(req as Request, res as Response, next) expect(res.status).toHaveBeenCalledWith(400) - expect(res.json).toHaveBeenCalledWith( - expect.objectContaining({ errors: expect.arrayContaining([expect.stringContaining('Invalid Stellar')]) }) - ) + expect(res.json).toHaveBeenCalledWith({ + message: 'Validation failed', + errors: { body: ['Invalid Stellar wallet address format'] } + }) }) it('returns 400 when walletAddress is too short', () => { @@ -284,9 +478,10 @@ describe('validateWalletAddress', () => { validateWalletAddress(req as Request, res as Response, next) expect(res.status).toHaveBeenCalledWith(400) - expect(res.json).toHaveBeenCalledWith( - expect.objectContaining({ errors: expect.arrayContaining([expect.stringContaining('Invalid Stellar')]) }) - ) + expect(res.json).toHaveBeenCalledWith({ + message: 'Validation failed', + errors: { body: ['Invalid Stellar wallet address format'] } + }) }) it('returns 400 when walletAddress contains lowercase characters', () => { @@ -295,9 +490,10 @@ describe('validateWalletAddress', () => { validateWalletAddress(req as Request, res as Response, next) expect(res.status).toHaveBeenCalledWith(400) - expect(res.json).toHaveBeenCalledWith( - expect.objectContaining({ errors: expect.arrayContaining([expect.stringContaining('Invalid Stellar')]) }) - ) + expect(res.json).toHaveBeenCalledWith({ + message: 'Validation failed', + errors: { body: ['Invalid Stellar wallet address format'] } + }) }) it('returns 400 when walletAddress is not a string', () => { @@ -306,6 +502,10 @@ describe('validateWalletAddress', () => { validateWalletAddress(req as Request, res as Response, next) expect(res.status).toHaveBeenCalledWith(400) + expect(res.json).toHaveBeenCalledWith({ + message: 'Validation failed', + errors: { body: ['Expected string, received number'] } + }) expect(next).not.toHaveBeenCalled() }) }) \ No newline at end of file