diff --git a/integrations/auth.controller.test.ts b/integrations/auth.controller.test.ts new file mode 100644 index 0000000..f916d4c --- /dev/null +++ b/integrations/auth.controller.test.ts @@ -0,0 +1,157 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { Request, Response } from 'express' +import { AuthController } from '../src/controllers/auth.controller' +import prisma from '../src/config/database' +import bcrypt from 'bcryptjs' +// import jwt from 'jsonwebtoken' + +// Mock dependencies +vi.mock('../src/config/database', () => ({ + default: { + user: { + findFirst: vi.fn(), + findUnique: vi.fn(), + create: vi.fn(), + update: vi.fn(), + }, + }, +})) + +vi.mock('bcryptjs', () => ({ + default: { + genSalt: vi.fn().mockResolvedValue('salt'), + hash: vi.fn().mockResolvedValue('hashed_password'), + compare: vi.fn(), + }, +})) + +vi.mock('jsonwebtoken', () => ({ + default: { + sign: vi.fn().mockReturnValue('mock_token'), + }, +})) + +describe('AuthController', () => { + let authController: AuthController + let mockRequest: Partial + let mockResponse: Partial + + beforeEach(() => { + authController = new AuthController() + mockRequest = {} + mockResponse = { + json: vi.fn(), + status: vi.fn().mockReturnThis(), + } + vi.clearAllMocks() + }) + + describe('register', () => { + it('should register a new user successfully', async () => { + mockRequest.body = { + email: 'test@example.com', + password: 'Password123!', + username: 'testuser', + }; + + (prisma.user.findFirst as any).mockResolvedValue(null); + (prisma.user.create as any).mockResolvedValue({ + id: '1', + email: 'test@example.com', + username: 'testuser', + role: 'LEARNER', + }) + + await authController.register(mockRequest as Request, mockResponse as Response) + + expect(prisma.user.create).toHaveBeenCalled() + expect(mockResponse.status).toHaveBeenCalledWith(201) + expect(mockResponse.json).toHaveBeenCalledWith(expect.objectContaining({ + message: 'User registered successfully', + token: 'mock_token', + })) + }) + + it('should return 400 for invalid input', async () => { + mockRequest.body = { + email: 'invalid-email', + password: 'short', + } + + await authController.register(mockRequest as Request, mockResponse as Response) + + expect(mockResponse.status).toHaveBeenCalledWith(400) + expect(mockResponse.json).toHaveBeenCalledWith(expect.objectContaining({ + error: 'Validation failed', + })) + }) + + it('should return 409 if user already exists', async () => { + mockRequest.body = { + email: 'exists@example.com', + password: 'Password123!', + username: 'exists', + }; + + (prisma.user.findFirst as any).mockResolvedValue({ id: '1' }) + + await authController.register(mockRequest as Request, mockResponse as Response) + + expect(mockResponse.status).toHaveBeenCalledWith(409) + expect(mockResponse.json).toHaveBeenCalledWith({ error: 'User with this email or username already exists' }) + }) + }) + + describe('login', () => { + it('should login successfully with valid credentials', async () => { + mockRequest.body = { + email: 'test@example.com', + password: 'Password123!', + } + + const mockUser = { + id: '1', + email: 'test@example.com', + password: 'hashed_password', + username: 'testuser', + role: 'LEARNER', + }; + + (prisma.user.findUnique as any).mockResolvedValue(mockUser); + (bcrypt.compare as any).mockResolvedValue(true); + (prisma.user.update as any).mockResolvedValue(mockUser) + + await authController.login(mockRequest as Request, mockResponse as Response) + + expect(mockResponse.status).toHaveBeenCalledWith(200) + expect(mockResponse.json).toHaveBeenCalledWith(expect.objectContaining({ + message: 'Login successful', + token: 'mock_token', + })) + }) + + it('should return 401 for invalid credentials', async () => { + mockRequest.body = { + email: 'test@example.com', + password: 'wrong_password', + }; + + (prisma.user.findUnique as any).mockResolvedValue({ id: '1', password: 'hashed' }); + (bcrypt.compare as any).mockResolvedValue(false) + + await authController.login(mockRequest as Request, mockResponse as Response) + + expect(mockResponse.status).toHaveBeenCalledWith(401) + expect(mockResponse.json).toHaveBeenCalledWith({ error: 'Invalid credentials' }) + }) + }) + + describe('logout', () => { + it('should return success message', async () => { + await authController.logout(mockRequest as Request, mockResponse as Response) + + expect(mockResponse.status).toHaveBeenCalledWith(200) + expect(mockResponse.json).toHaveBeenCalledWith({ message: 'Logged out successfully. Please clear your token client-side.' }) + }) + }) +}) diff --git a/integrations/e2e/.gitkeep b/integrations/e2e/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/integrations/error.middleware.test.ts b/integrations/error.middleware.test.ts new file mode 100644 index 0000000..129f113 --- /dev/null +++ b/integrations/error.middleware.test.ts @@ -0,0 +1,436 @@ +import { describe, it, expect, vi, beforeEach, type MockInstance } from 'vitest' +import { Request, Response, NextFunction } from 'express' +import { + errorHandler, + notFoundHandler, + asyncHandler, +} from '../src/middleware/error.middleware' +import { + AppError, + BadRequestError, + UnauthorizedError, + ForbiddenError, + NotFoundError, + ConflictError, + ValidationError, + InternalServerError, +} from '../src/utils/errors' +import logger from '../src/config/logger' + +// Mock the logger +vi.mock('../src/config/logger', () => ({ + default: { + error: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + }, +})) + +// Mock the env config +vi.mock('../src/config/env', () => ({ + env: { + NODE_ENV: 'development', + PORT: 3000, + }, +})) + +describe('Error Handling Middleware', () => { + let mockRequest: Partial + let mockResponse: Partial> + let mockNext: MockInstance + // ReturnType satisfies Express's strict method signatures + // by letting TypeScript infer the call signatures from the mock factory. + let jsonMock: ReturnType + let statusMock: ReturnType + + beforeEach(() => { + vi.clearAllMocks() + + jsonMock = vi.fn().mockReturnValue({}) + statusMock = vi.fn().mockReturnValue({ json: jsonMock }) + + mockRequest = { + path: '/api/test', + method: 'GET', + headers: { 'content-type': 'application/json' }, + } + + mockResponse = { + status: statusMock as unknown as Response['status'], + json: jsonMock as unknown as Response['json'], + } + + mockNext = vi.fn() + }) + + describe('AppError Class', () => { + it('should create an AppError with proper properties', () => { + const error = new AppError('Test error', 400) + expect(error.message).toBe('Test error') + expect(error.statusCode).toBe(400) + expect(error.isOperational).toBe(true) + expect(error.name).toBe('AppError') + }) + + it('should set default status code to 500', () => { + const error = new AppError('Test error') + expect(error.statusCode).toBe(500) + }) + + it('should maintain stack trace', () => { + const error = new AppError('Test error') + expect(error.stack).toBeDefined() + expect(error.stack).toContain('AppError') + }) + }) + + describe('Custom Error Classes', () => { + it('should create BadRequestError with 400 status', () => { + const error = new BadRequestError('Invalid input') + expect(error.statusCode).toBe(400) + expect(error.message).toBe('Invalid input') + }) + + it('should create UnauthorizedError with 401 status', () => { + const error = new UnauthorizedError('Not authenticated') + expect(error.statusCode).toBe(401) + expect(error.message).toBe('Not authenticated') + }) + + it('should create ForbiddenError with 403 status', () => { + const error = new ForbiddenError('No access') + expect(error.statusCode).toBe(403) + expect(error.message).toBe('No access') + }) + + it('should create NotFoundError with 404 status', () => { + const error = new NotFoundError('User not found') + expect(error.statusCode).toBe(404) + expect(error.message).toBe('User not found') + }) + + it('should create ConflictError with 409 status', () => { + const error = new ConflictError('Email already exists') + expect(error.statusCode).toBe(409) + expect(error.message).toBe('Email already exists') + }) + + it('should create ValidationError with 422 status and errors', () => { + const errors = { + email: ['Invalid email format'], + password: ['Password too short'], + } + const error = new ValidationError('Validation failed', errors) + expect(error.statusCode).toBe(422) + expect(error.errors).toEqual(errors) + }) + + it('should create InternalServerError with 500 status', () => { + const error = new InternalServerError('Database connection failed') + expect(error.statusCode).toBe(500) + expect(error.isOperational).toBe(false) + }) + }) + + describe('errorHandler Middleware', () => { + it('should handle AppError with correct status code', () => { + const error = new BadRequestError('Invalid input') + errorHandler( + error, + mockRequest as Request, + mockResponse as Response, + mockNext as unknown as NextFunction, + ) + + expect(statusMock).toHaveBeenCalledWith(400) + expect(jsonMock).toHaveBeenCalled() + }) + + it('should return consistent error response format', () => { + const error = new NotFoundError('User not found') + errorHandler( + error, + mockRequest as Request, + mockResponse as Response, + mockNext as unknown as NextFunction, + ) + + const response = jsonMock.mock.calls[0][0] + expect(response).toHaveProperty('success', false) + expect(response).toHaveProperty('error') + expect(response.error).toHaveProperty('message') + expect(response.error).toHaveProperty('code') + }) + + it('should handle regular Error by converting to InternalServerError', () => { + const error = new Error('Something went wrong') + errorHandler( + error, + mockRequest as Request, + mockResponse as Response, + mockNext as unknown as NextFunction, + ) + + expect(statusMock).toHaveBeenCalledWith(500) + expect(logger.error).toHaveBeenCalled() + }) + + it('should include stack trace in development mode', () => { + const error = new AppError('Test error', 400) + errorHandler( + error, + mockRequest as Request, + mockResponse as Response, + mockNext as unknown as NextFunction, + ) + + const response = jsonMock.mock.calls[0][0] + expect(response.error).toHaveProperty('stack') + }) + + it('should include request context in development mode', () => { + const error = new AppError('Test error', 400) + errorHandler( + error, + mockRequest as Request, + mockResponse as Response, + mockNext as unknown as NextFunction, + ) + + const response = jsonMock.mock.calls[0][0] + expect(response.error).toHaveProperty('request') + expect(response.error.request).toHaveProperty('method') + expect(response.error.request).toHaveProperty('path') + }) + + it('should include validation errors in response', () => { + const validationErrors = { + email: ['Invalid email'], + } + const error = new ValidationError('Validation failed', validationErrors) + errorHandler( + error, + mockRequest as Request, + mockResponse as Response, + mockNext as unknown as NextFunction, + ) + + const response = jsonMock.mock.calls[0][0] + expect(response.error).toHaveProperty('details') + expect(response.error.details).toEqual(validationErrors) + }) + + it('should log error with correct information', () => { + const error = new AppError('Test error', 500) + errorHandler( + error, + mockRequest as Request, + mockResponse as Response, + mockNext as unknown as NextFunction, + ) + + expect(logger.error).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Test error', + path: '/api/test', + method: 'GET', + }) + ) + }) + + it('should set correct status code for default 500 errors', () => { + const error = new Error('Unhandled error') + errorHandler( + error, + mockRequest as Request, + mockResponse as Response, + mockNext as unknown as NextFunction, + ) + + expect(statusMock).toHaveBeenCalledWith(500) + }) + }) + + describe('notFoundHandler Middleware', () => { + it('should call next with NotFoundError', () => { + notFoundHandler( + mockRequest as Request, + mockResponse as Response, + mockNext as unknown as NextFunction, + ) + + const errorArg = mockNext.mock.calls[0][0] + expect(errorArg).toBeInstanceOf(NotFoundError) + }) + + it('should include correct path and method in error message', () => { + notFoundHandler( + mockRequest as Request, + mockResponse as Response, + mockNext as unknown as NextFunction, + ) + + const error = mockNext.mock.calls[0][0] + expect(error.message).toContain('GET') + expect(error.message).toContain('/api/test') + }) + + it('should log warning when route not found', () => { + notFoundHandler( + mockRequest as Request, + mockResponse as Response, + mockNext as unknown as NextFunction, + ) + + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Not Found', + path: '/api/test', + method: 'GET', + }) + ) + }) + + it('should have correct error status code', () => { + notFoundHandler( + mockRequest as Request, + mockResponse as Response, + mockNext as unknown as NextFunction, + ) + + const error = mockNext.mock.calls[0][0] + expect(error.statusCode).toBe(404) + }) + }) + + describe('asyncHandler Wrapper', () => { + it('should execute async handler successfully', async () => { + const asyncFn = vi.fn(async (req, res) => { + res.json({ success: true }) + }) + + const wrappedFn = asyncHandler(asyncFn) + wrappedFn( + mockRequest as Request, + mockResponse as Response, + mockNext as unknown as NextFunction, + ) + + await new Promise((resolve) => setTimeout(resolve, 10)) + + expect(asyncFn).toHaveBeenCalled() + }) + + it('should catch promise rejections', async () => { + const error = new Error('Async error') + const asyncFn = vi.fn(async () => { + throw error + }) + + const wrappedFn = asyncHandler(asyncFn) + wrappedFn( + mockRequest as Request, + mockResponse as Response, + mockNext as unknown as NextFunction, + ) + + await new Promise((resolve) => setTimeout(resolve, 10)) + + expect(mockNext).toHaveBeenCalledWith(error) + }) + + it('should log error when async handler fails', async () => { + const error = new Error('Database error') + const asyncFn = vi.fn(async () => { + throw error + }) + + const wrappedFn = asyncHandler(asyncFn) + wrappedFn( + mockRequest as Request, + mockResponse as Response, + mockNext as unknown as NextFunction, + ) + + await new Promise((resolve) => setTimeout(resolve, 10)) + + expect(logger.error).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Async error caught', + error: 'Database error', + }) + ) + }) + + it('should preserve request and response context', async () => { + const asyncFn = vi.fn(async (req, res) => { + expect(req.path).toBe('/api/test') + res.json({ success: true }) + }) + + const wrappedFn = asyncHandler(asyncFn) + wrappedFn( + mockRequest as Request, + mockResponse as Response, + mockNext as unknown as NextFunction, + ) + + await new Promise((resolve) => setTimeout(resolve, 10)) + + expect(asyncFn).toHaveBeenCalled() + }) + }) + + describe('Error Response Format Consistency', () => { + const testErrorScenarios = [ + { + name: 'BadRequestError', + error: new BadRequestError('Invalid email'), + expectedStatus: 400, + }, + { + name: 'UnauthorizedError', + error: new UnauthorizedError('Invalid token'), + expectedStatus: 401, + }, + { + name: 'ForbiddenError', + error: new ForbiddenError('Insufficient permissions'), + expectedStatus: 403, + }, + { + name: 'NotFoundError', + error: new NotFoundError('User not found'), + expectedStatus: 404, + }, + { + name: 'ConflictError', + error: new ConflictError('Duplicate entry'), + expectedStatus: 409, + }, + { + name: 'ValidationError', + error: new ValidationError('Invalid data', { field: ['error'] }), + expectedStatus: 422, + }, + ] + + testErrorScenarios.forEach(({ name, error, expectedStatus }) => { + it(`should format ${name} response correctly`, () => { + errorHandler( + error, + mockRequest as Request, + mockResponse as Response, + mockNext as unknown as NextFunction, + ) + + expect(statusMock).toHaveBeenCalledWith(expectedStatus) + + const response = jsonMock.mock.calls[0][0] + expect(response).toHaveProperty('success', false) + expect(response.error).toHaveProperty('message') + expect(response.error).toHaveProperty('code') + }) + }) + }) +}) \ No newline at end of file diff --git a/integrations/integration/.gitkeep b/integrations/integration/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/integrations/services/webhook.service.spec.ts b/integrations/services/webhook.service.spec.ts new file mode 100644 index 0000000..2926f37 --- /dev/null +++ b/integrations/services/webhook.service.spec.ts @@ -0,0 +1,124 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { WebhookService } from '../../src/services/webhook.service' + +const { mockPrismaInstance } = vi.hoisted(() => ({ + mockPrismaInstance: { + webhookEndpoint: { + create: vi.fn(), + findMany: vi.fn(), + update: vi.fn(), + }, + webhookDelivery: { + create: vi.fn(), + findMany: vi.fn(), + update: vi.fn(), + }, + }, +})) + +vi.mock('@prisma/client', () => ({ + PrismaClient: class { + webhookEndpoint = mockPrismaInstance.webhookEndpoint + webhookDelivery = mockPrismaInstance.webhookDelivery + }, +})) + +// Mock global fetch +global.fetch = vi.fn() + +describe('WebhookService', () => { + let service: WebhookService + + beforeEach(() => { + vi.clearAllMocks() + service = new WebhookService() + }) + + describe('registerEndpoint', () => { + it('should create a new endpoint with a generated secret', async () => { + const data = { + url: 'https://example.com/webhook', + events: ['module.completed' as any], + } + + mockPrismaInstance.webhookEndpoint.create.mockResolvedValue({ id: '1', ...data, secret: 'secret' }) + + const result = await service.registerEndpoint(data) + + expect(mockPrismaInstance.webhookEndpoint.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + url: data.url, + events: 'module.completed', + }), + }) + ) + expect(result.id).toBe('1') + }) + }) + + describe('queueEvent', () => { + it('should create deliveries for subscribed endpoints', async () => { + mockPrismaInstance.webhookEndpoint.findMany.mockResolvedValue([ + { id: 'ep1', url: 'https://ep1.com', secret: 's1', events: 'module.completed', isActive: true }, + ]) + mockPrismaInstance.webhookDelivery.create.mockResolvedValue({ id: 'd1' }) + mockPrismaInstance.webhookDelivery.findMany.mockResolvedValue([]) // for processQueue + + await service.queueEvent('module.completed', { foo: 'bar' }) + + expect(mockPrismaInstance.webhookDelivery.create).toHaveBeenCalledOnce() + const createCall = mockPrismaInstance.webhookDelivery.create.mock.calls[0][0] + expect(createCall.data.eventType).toBe('module.completed') + expect(JSON.parse(createCall.data.payload).data).toEqual({ foo: 'bar' }) + }) + }) + + describe('signature generation', () => { + it('should generate a valid HMAC SHA256 signature', () => { + const payload = '{"foo":"bar"}' + const secret = 'test-secret' + // @ts-expect-error just ignore for now + const signature = service.generateSignature(payload, secret) + + expect(signature).toBeDefined() + expect(signature).toHaveLength(64) + }) + }) + + describe('retry logic', () => { + it('should calculate exponential backoff', async () => { + const delivery = { id: 'd1', attemptCount: 1, maxAttempts: 5 } + mockPrismaInstance.webhookDelivery.update.mockResolvedValue({}) + + // @ts-expect-error just ignore for now + await service.handleFailure(delivery, 'error') + + expect(mockPrismaInstance.webhookDelivery.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + nextAttemptAt: expect.any(Date), + }), + }) + ) + }) + + it('should handle terminal failure after max attempts', async () => { + const delivery = { id: 'd1', attemptCount: 4, maxAttempts: 5, endpointId: 'ep1' } + mockPrismaInstance.webhookDelivery.update.mockResolvedValue({}) + mockPrismaInstance.webhookDelivery.findMany.mockResolvedValue([]) + + // @ts-expect-error just ignore for now + await service.handleFailure(delivery, 'max retry error') + + expect(mockPrismaInstance.webhookDelivery.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + status: 'failed', + }), + }) + ) + }) + }) +}) diff --git a/integrations/setup.ts b/integrations/setup.ts new file mode 100644 index 0000000..e98dec3 --- /dev/null +++ b/integrations/setup.ts @@ -0,0 +1,3 @@ +import { config } from 'dotenv' + +config({ path: '.env.test' }) \ No newline at end of file diff --git a/integrations/user.controller.test.ts b/integrations/user.controller.test.ts new file mode 100644 index 0000000..6137cc5 --- /dev/null +++ b/integrations/user.controller.test.ts @@ -0,0 +1,282 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { Request, Response } from 'express' +import { UserController } from '../src/controllers/user.controller' +import { User } from '../src/types/user.types' + +interface AuthRequest extends Request { + user?: { + id: string; + email: string; + }; +} + +describe('UserController', () => { + let userController: UserController + let mockRequest: Partial + let mockResponse: Partial + + beforeEach(() => { + userController = new UserController() + mockRequest = {} + mockResponse = { + json: vi.fn(), + status: vi.fn().mockReturnThis(), + } + }) + + describe('getCurrentUser', () => { + it('should return current user profile', async () => { + const mockUser: User = { + id: '1', + email: 'test@example.com', + username: 'testuser', + firstName: 'Test', + lastName: 'User', + bio: 'Test bio', + avatar: 'https://example.com/avatar.jpg', + walletAddress: 'GABC123456789012345678901234567890123456789012345678901234567890', + isActive: true, + createdAt: new Date(), + updatedAt: new Date(), + } + + mockRequest.user = { id: '1', email: 'test@example.com' } + + vi.spyOn(userController as any, 'findUserById').mockResolvedValue(mockUser) + + await userController.getCurrentUser(mockRequest as Request, mockResponse as Response) + + expect(mockResponse.json).toHaveBeenCalledWith({ + id: mockUser.id, + email: mockUser.email, + username: mockUser.username, + firstName: mockUser.firstName, + lastName: mockUser.lastName, + bio: mockUser.bio, + avatar: mockUser.avatar, + walletAddress: mockUser.walletAddress, + isActive: mockUser.isActive, + createdAt: mockUser.createdAt, + updatedAt: mockUser.updatedAt, + }) + }) + + it('should return 404 if user not found', async () => { + mockRequest.user = { id: '1', email: 'test@example.com' } + + vi.spyOn(userController as any, 'findUserById').mockResolvedValue(null) + + await userController.getCurrentUser(mockRequest as Request, mockResponse as Response) + + expect(mockResponse.status).toHaveBeenCalledWith(404) + expect(mockResponse.json).toHaveBeenCalledWith({ error: 'User not found' }) + }) + }) + + describe('updateProfile', () => { + it('should update user profile successfully', async () => { + const mockUser: User = { + id: '1', + email: 'test@example.com', + username: 'updateduser', + firstName: 'Updated', + lastName: 'User', + bio: 'Updated bio', + avatar: 'https://example.com/new-avatar.jpg', + walletAddress: 'GABC123456789012345678901234567890123456789012345678901234567890', + isActive: true, + createdAt: new Date(), + updatedAt: new Date(), + } + + mockRequest.user = { id: '1', email: 'test@example.com' } + mockRequest.body = { + username: 'updateduser', + firstName: 'Updated', + lastName: 'User', + bio: 'Updated bio', + avatar: 'https://example.com/new-avatar.jpg', + } + + vi.spyOn(userController as any, 'updateUserProfile').mockResolvedValue(mockUser) + + await userController.updateProfile(mockRequest as Request, mockResponse as Response) + + expect(mockResponse.json).toHaveBeenCalledWith({ + id: mockUser.id, + email: mockUser.email, + username: mockUser.username, + firstName: mockUser.firstName, + lastName: mockUser.lastName, + bio: mockUser.bio, + avatar: mockUser.avatar, + walletAddress: mockUser.walletAddress, + isActive: mockUser.isActive, + createdAt: mockUser.createdAt, + updatedAt: mockUser.updatedAt, + }) + }) + }) + + describe('getUserById', () => { + it('should return public user info', async () => { + const mockUser: User = { + id: '1', + email: 'test@example.com', + username: 'testuser', + firstName: 'Test', + lastName: 'User', + bio: 'Test bio', + avatar: 'https://example.com/avatar.jpg', + walletAddress: 'GABC123456789012345678901234567890123456789012345678901234567890', + isActive: true, + createdAt: new Date(), + updatedAt: new Date(), + } + + mockRequest.params = { id: '1' } + + vi.spyOn(userController as any, 'findUserById').mockResolvedValue(mockUser) + + await userController.getUserById(mockRequest as Request, mockResponse as Response) + + expect(mockResponse.json).toHaveBeenCalledWith({ + id: mockUser.id, + username: mockUser.username, + firstName: mockUser.firstName, + lastName: mockUser.lastName, + avatar: mockUser.avatar, + createdAt: mockUser.createdAt, + }) + }) + + it('should return 404 if user not found', async () => { + mockRequest.params = { id: '1' } + + vi.spyOn(userController as any, 'findUserById').mockResolvedValue(null) + + await userController.getUserById(mockRequest as Request, mockResponse as Response) + + expect(mockResponse.status).toHaveBeenCalledWith(404) + expect(mockResponse.json).toHaveBeenCalledWith({ error: 'User not found' }) + }) + }) + + describe('changePassword', () => { + it('should change password successfully', async () => { + const mockUser: User = { + id: '1', + email: 'test@example.com', + username: 'testuser', + isActive: true, + createdAt: new Date(), + updatedAt: new Date(), + } + + mockRequest.user = { id: '1', email: 'test@example.com' } + mockRequest.body = { + currentPassword: 'oldpassword', + newPassword: 'NewPassword123!', + } + + vi.spyOn(userController as any, 'findUserById').mockResolvedValue(mockUser) + vi.spyOn(userController as any, 'validatePassword').mockResolvedValue(true) + vi.spyOn(userController as any, 'updateUserPassword').mockResolvedValue(undefined) + + await userController.changePassword(mockRequest as Request, mockResponse as Response) + + expect(mockResponse.json).toHaveBeenCalledWith({ message: 'Password updated successfully' }) + }) + + it('should return 400 if current password is incorrect', async () => { + const mockUser: User = { + id: '1', + email: 'test@example.com', + username: 'testuser', + isActive: true, + createdAt: new Date(), + updatedAt: new Date(), + } + + mockRequest.user = { id: '1', email: 'test@example.com' } + mockRequest.body = { + currentPassword: 'wrongpassword', + newPassword: 'NewPassword123!', + } + + vi.spyOn(userController as any, 'findUserById').mockResolvedValue(mockUser) + vi.spyOn(userController as any, 'validatePassword').mockResolvedValue(false) + + await userController.changePassword(mockRequest as Request, mockResponse as Response) + + expect(mockResponse.status).toHaveBeenCalledWith(400) + expect(mockResponse.json).toHaveBeenCalledWith({ error: 'Current password is incorrect' }) + }) + }) + + describe('updateWalletAddress', () => { + it('should update wallet address successfully', async () => { + const mockUser: User = { + id: '1', + email: 'test@example.com', + username: 'testuser', + walletAddress: 'GABC1234567890123456789012345678901234567890123456789', + isActive: true, + createdAt: new Date(), + updatedAt: new Date(), + } + + mockRequest.user = { id: '1', email: 'test@example.com' } + mockRequest.body = { + walletAddress: 'GABC1234567890123456789012345678901234567890123456789', + } + + vi.spyOn(userController as any, 'updateUserWallet').mockResolvedValue(mockUser) + + await userController.updateWalletAddress(mockRequest as Request, mockResponse as Response) + + expect(mockResponse.json).toHaveBeenCalledWith({ + id: mockUser.id, + email: mockUser.email, + username: mockUser.username, + firstName: mockUser.firstName, + lastName: mockUser.lastName, + bio: mockUser.bio, + avatar: mockUser.avatar, + walletAddress: mockUser.walletAddress, + isActive: mockUser.isActive, + createdAt: mockUser.createdAt, + updatedAt: mockUser.updatedAt, + }) + }) + + it('should return 400 for invalid wallet address', async () => { + mockRequest.user = { id: '1', email: 'test@example.com' } + mockRequest.body = { + walletAddress: 'invalid-address', + } + + await userController.updateWalletAddress(mockRequest as Request, mockResponse as Response) + + expect(mockResponse.status).toHaveBeenCalledWith(400) + expect(mockResponse.json).toHaveBeenCalledWith({ error: 'Invalid Stellar wallet address' }) + }) + }) + + describe('isValidStellarAddress', () => { + it('should validate correct Stellar address', () => { + const validAddress = 'GABC1234567890123456789012345678901234567890123456789' + expect((userController as any).isValidStellarAddress(validAddress)).toBe(true) + }) + + it('should reject invalid Stellar address', () => { + const invalidAddress = 'invalid-address' + expect((userController as any).isValidStellarAddress(invalidAddress)).toBe(false) + }) + + it('should reject address with wrong length', () => { + const shortAddress = 'GABC123' + expect((userController as any).isValidStellarAddress(shortAddress)).toBe(false) + }) + }) +})