diff --git a/docs/API.md b/docs/API.md index 3a7f7e56..6df9621a 100644 --- a/docs/API.md +++ b/docs/API.md @@ -57,19 +57,13 @@ GET /v1/users/me ```json { - "status": "success", - "data": { - "id": "usr_123", - "email": "user@example.com", - "name": "John Doe", - "walletAddress": "GABC...123", - "createdAt": "2024-01-01T00:00:00Z", - "stats": { - "modulesCompleted": 15, - "totalEarned": "25.50", - "currentStreak": 7 - } - } + "id": "usr_123", + "email": "user@example.com", + "username": "john_doe", + "role": "LEARNER", + "walletAddress": "GABC...123", + "createdAt": "2024-01-01T00:00:00Z", + "updatedAt": "2024-01-02T00:00:00Z" } ``` @@ -83,11 +77,36 @@ PATCH /v1/users/me ```json { - "name": "John Updated", - "preferences": { - "language": "fr", - "notifications": true - } + "username": "john_updated" +} +``` + +#### Change Password + +```txt +PATCH /v1/users/password +``` + +**Request:** + +```json +{ + "currentPassword": "OldPassword123!", + "newPassword": "NewPassword123!" +} +``` + +#### Update Wallet Address + +```txt +PATCH /v1/users/wallet +``` + +**Request:** + +```json +{ + "walletAddress": "GABC...123" } ``` diff --git a/integrations/unit/validation.middleware.test.ts b/integrations/unit/validation.middleware.test.ts index d7a34d04..3fba43ea 100644 --- a/integrations/unit/validation.middleware.test.ts +++ b/integrations/unit/validation.middleware.test.ts @@ -200,13 +200,7 @@ describe('validate', () => { describe('validateProfileUpdate', () => { it('calls next() for a valid update payload', () => { - const { req, res, next } = makeMocks({ - username: 'valid_user', - firstName: 'John', - lastName: 'Doe', - bio: 'Hello world', - avatar: 'https://example.com/avatar.jpg', - }) + const { req, res, next } = makeMocks({ username: 'valid_user' }) validateProfileUpdate(req as Request, res as Response, next) @@ -259,65 +253,22 @@ describe('validateProfileUpdate', () => { }) }) - it('returns 400 when firstName exceeds 50 characters', () => { - const { req, res, next } = makeMocks({ firstName: 'A'.repeat(51) }) - - validateProfileUpdate(req as Request, res as Response, next) - - expect(res.status).toHaveBeenCalledWith(400) - 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', () => { - const { req, res, next } = makeMocks({ lastName: 'B'.repeat(51) }) - - validateProfileUpdate(req as Request, res as Response, next) - - expect(res.status).toHaveBeenCalledWith(400) - 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', () => { - const { req, res, next } = makeMocks({ bio: 'x'.repeat(501) }) - - validateProfileUpdate(req as Request, res as Response, next) - - expect(res.status).toHaveBeenCalledWith(400) - 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', () => { - const { req, res, next } = makeMocks({ avatar: 'not-a-url' }) - - validateProfileUpdate(req as Request, res as Response, next) - - expect(res.status).toHaveBeenCalledWith(400) - expect(res.json).toHaveBeenCalledWith({ - message: 'Validation failed', - errors: { body: ['Invalid URL format'] } - }) - }) - - it('returns multiple errors when multiple fields are invalid', () => { + it('ignores unsupported fields (firstName, lastName, bio, avatar)', () => { const { req, res, next } = makeMocks({ - username: 'ab', - bio: 'x'.repeat(501), + username: 'valid_user', + firstName: 'John', + lastName: 'Doe', + bio: 'Hello world', + avatar: 'https://example.com/avatar.jpg', }) validateProfileUpdate(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.body.length).toBeGreaterThanOrEqual(2) + expect(next).toHaveBeenCalledOnce() + expect((req as any).body.firstName).toBeUndefined() + expect((req as any).body.lastName).toBeUndefined() + expect((req as any).body.bio).toBeUndefined() + expect((req as any).body.avatar).toBeUndefined() }) }) diff --git a/integrations/user.controller.test.ts b/integrations/user.controller.test.ts index 6137cc5d..c1b045b8 100644 --- a/integrations/user.controller.test.ts +++ b/integrations/user.controller.test.ts @@ -1,282 +1,231 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' +import { Prisma } from '@prisma/client' +import bcrypt from 'bcryptjs' import { Request, Response } from 'express' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import prisma from '../src/config/database' import { UserController } from '../src/controllers/user.controller' -import { User } from '../src/types/user.types' -interface AuthRequest extends Request { - user?: { - id: string; - email: string; - }; +vi.mock('../src/config/database', () => ({ + default: { + user: { + findUnique: vi.fn(), + update: vi.fn(), + }, + }, +})) + +vi.mock('bcryptjs', () => ({ + default: { + compare: vi.fn(), + hash: vi.fn(), + }, +})) + +function createResponse() { + const response: Partial = {} + response.status = vi.fn().mockReturnValue(response) + response.json = vi.fn().mockReturnValue(response) + + return response as Response } +const persistedUser = { + id: '1', + email: 'user@example.com', + username: 'testuser', + password: 'hashed_password', + role: 'LEARNER', + walletAddress: null, + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-02T00:00:00Z'), + lastLoginAt: null, +} + +const validWalletAddress = 'GABC1234567890123456789012345678901234567890123456789' + describe('UserController', () => { - let userController: UserController - let mockRequest: Partial - let mockResponse: Partial + let controller: UserController beforeEach(() => { - userController = new UserController() - mockRequest = {} - mockResponse = { - json: vi.fn(), - status: vi.fn().mockReturnThis(), - } + vi.clearAllMocks() + controller = new UserController() }) describe('getCurrentUser', () => { - it('should return current user profile', async () => { - const mockUser: User = { + it('returns the persisted row without leaking the password', async () => { + ;(prisma.user.findUnique as any).mockResolvedValue(persistedUser) + + const req = { user: { id: '1' } } as unknown as Request + const res = createResponse() + + await controller.getCurrentUser(req, res) + + expect(prisma.user.findUnique).toHaveBeenCalledWith({ where: { id: '1' } }) + expect(res.json).toHaveBeenCalledWith({ id: '1', - email: 'test@example.com', + email: 'user@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, + role: 'LEARNER', + walletAddress: null, + createdAt: persistedUser.createdAt, + updatedAt: persistedUser.updatedAt, }) + expect(JSON.stringify((res.json as any).mock.calls[0][0])).not.toContain('hashed_password') }) - 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) + it('returns 404 when the user does not exist', async () => { + ;(prisma.user.findUnique as any).mockResolvedValue(null) + + const req = { user: { id: 'missing' } } as unknown as Request + const res = createResponse() - await userController.getCurrentUser(mockRequest as Request, mockResponse as Response) + await controller.getCurrentUser(req, res) - expect(mockResponse.status).toHaveBeenCalledWith(404) - expect(mockResponse.json).toHaveBeenCalledWith({ error: 'User not found' }) + expect(res.status).toHaveBeenCalledWith(404) + expect(res.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, + it('persists the username via prisma.user.update and returns the row', async () => { + const updated = { ...persistedUser, username: 'updateduser' } + ;(prisma.user.update as any).mockResolvedValue(updated) + + const req = { user: { id: '1' }, body: { username: 'updateduser' } } as unknown as Request + const res = createResponse() + + await controller.updateProfile(req, res) + + expect(prisma.user.update).toHaveBeenCalledWith({ + where: { id: '1' }, + data: { username: 'updateduser' }, }) + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ username: 'updateduser' })) }) }) describe('getUserById', () => { - it('should return public user info', async () => { - const mockUser: User = { + it('returns public user info', async () => { + ;(prisma.user.findUnique as any).mockResolvedValue(persistedUser) + + const req = { params: { id: '1' } } as unknown as Request + const res = createResponse() + + await controller.getUserById(req, res) + + expect(res.json).toHaveBeenCalledWith({ 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, + role: 'LEARNER', + createdAt: persistedUser.createdAt, }) }) - it('should return 404 if user not found', async () => { - mockRequest.params = { id: '1' } - - vi.spyOn(userController as any, 'findUserById').mockResolvedValue(null) + it('returns 404 when the user does not exist', async () => { + ;(prisma.user.findUnique as any).mockResolvedValue(null) - await userController.getUserById(mockRequest as Request, mockResponse as Response) + const req = { params: { id: 'missing' } } as unknown as Request + const res = createResponse() - expect(mockResponse.status).toHaveBeenCalledWith(404) - expect(mockResponse.json).toHaveBeenCalledWith({ error: 'User not found' }) + await controller.getUserById(req, res) + + expect(res.status).toHaveBeenCalledWith(404) }) }) 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(), - } + it('verifies the current password, hashes, and persists the new password', async () => { + ;(prisma.user.findUnique as any).mockResolvedValue(persistedUser) + ;(bcrypt.compare as any).mockResolvedValue(true) + ;(bcrypt.hash as any).mockResolvedValue('new_hash') + + const req = { + user: { id: '1' }, + body: { currentPassword: 'OldPassword123!', newPassword: 'NewPassword123!' }, + } as unknown as Request + const res = createResponse() + + await controller.changePassword(req, res) + + expect(bcrypt.compare).toHaveBeenCalledWith('OldPassword123!', 'hashed_password') + expect(bcrypt.hash).toHaveBeenCalledWith('NewPassword123!', 10) + expect(prisma.user.update).toHaveBeenCalledWith({ + where: { id: '1' }, + data: { password: 'new_hash' }, + }) + expect(res.json).toHaveBeenCalledWith({ message: 'Password updated successfully' }) + }) - mockRequest.user = { id: '1', email: 'test@example.com' } - mockRequest.body = { - currentPassword: 'oldpassword', - newPassword: 'NewPassword123!', - } + it('returns 400 and does not persist when the current password is incorrect', async () => { + ;(prisma.user.findUnique as any).mockResolvedValue(persistedUser) + ;(bcrypt.compare as any).mockResolvedValue(false) - vi.spyOn(userController as any, 'findUserById').mockResolvedValue(mockUser) - vi.spyOn(userController as any, 'validatePassword').mockResolvedValue(true) - vi.spyOn(userController as any, 'updateUserPassword').mockResolvedValue(undefined) + const req = { + user: { id: '1' }, + body: { currentPassword: 'WrongPassword123!', newPassword: 'NewPassword123!' }, + } as unknown as Request + const res = createResponse() - await userController.changePassword(mockRequest as Request, mockResponse as Response) + await controller.changePassword(req, res) - expect(mockResponse.json).toHaveBeenCalledWith({ message: 'Password updated successfully' }) + expect(res.status).toHaveBeenCalledWith(400) + expect(res.json).toHaveBeenCalledWith({ error: 'Current password is incorrect' }) + expect(prisma.user.update).not.toHaveBeenCalled() }) + }) - 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!', - } + describe('updateWalletAddress', () => { + it('persists the wallet address and returns the row', async () => { + const updated = { ...persistedUser, walletAddress: validWalletAddress } + ;(prisma.user.update as any).mockResolvedValue(updated) - vi.spyOn(userController as any, 'findUserById').mockResolvedValue(mockUser) - vi.spyOn(userController as any, 'validatePassword').mockResolvedValue(false) + const req = { user: { id: '1' }, body: { walletAddress: validWalletAddress } } as unknown as Request + const res = createResponse() - await userController.changePassword(mockRequest as Request, mockResponse as Response) + await controller.updateWalletAddress(req, res) - expect(mockResponse.status).toHaveBeenCalledWith(400) - expect(mockResponse.json).toHaveBeenCalledWith({ error: 'Current password is incorrect' }) + expect(prisma.user.update).toHaveBeenCalledWith({ + where: { id: '1' }, + data: { walletAddress: validWalletAddress }, + }) + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ walletAddress: validWalletAddress })) }) - }) - 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('returns 409 on a duplicate wallet address', async () => { + ;(prisma.user.update as any).mockRejectedValue( + new Prisma.PrismaClientKnownRequestError('Unique constraint failed', { + code: 'P2002', + clientVersion: '7.4.2', + }), + ) + + const req = { user: { id: '1' }, body: { walletAddress: validWalletAddress } } as unknown as Request + const res = createResponse() + + await controller.updateWalletAddress(req, res) + + expect(res.status).toHaveBeenCalledWith(409) + expect(res.json).toHaveBeenCalledWith({ error: 'Wallet address is already in use' }) }) - it('should return 400 for invalid wallet address', async () => { - mockRequest.user = { id: '1', email: 'test@example.com' } - mockRequest.body = { - walletAddress: 'invalid-address', - } + it('returns 400 for an invalid wallet address', async () => { + const req = { user: { id: '1' }, body: { walletAddress: 'invalid-address' } } as unknown as Request + const res = createResponse() - await userController.updateWalletAddress(mockRequest as Request, mockResponse as Response) + await controller.updateWalletAddress(req, res) - expect(mockResponse.status).toHaveBeenCalledWith(400) - expect(mockResponse.json).toHaveBeenCalledWith({ error: 'Invalid Stellar wallet address' }) + expect(res.status).toHaveBeenCalledWith(400) + expect(res.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('validates a correct Stellar address', () => { + expect((controller as any).isValidStellarAddress(validWalletAddress)).toBe(true) }) - it('should reject address with wrong length', () => { - const shortAddress = 'GABC123' - expect((userController as any).isValidStellarAddress(shortAddress)).toBe(false) + it('rejects an invalid Stellar address', () => { + expect((controller as any).isValidStellarAddress('invalid-address')).toBe(false) }) }) }) diff --git a/src/controllers/user.controller.ts b/src/controllers/user.controller.ts index b813b11a..2ae0d991 100644 --- a/src/controllers/user.controller.ts +++ b/src/controllers/user.controller.ts @@ -1,5 +1,15 @@ -import { ChangePasswordData, PublicUserInfo, UpdateUserData, User } from '../types/user.types' +import { Prisma } from '@prisma/client' +import bcrypt from 'bcryptjs' import { Request, Response } from 'express' +import prisma from '../config/database' +import { ChangePasswordData, PublicUserInfo, UpdateUserData, User } from '../types/user.types' + +class WalletConflictError extends Error { + constructor() { + super('Wallet address is already in use') + this.name = 'WalletConflictError' + } +} export class UserController { /** @@ -37,19 +47,7 @@ export class UserController { return } - res.json({ - id: user.id, - email: user.email, - username: user.username, - firstName: user.firstName, - lastName: user.lastName, - bio: user.bio, - avatar: user.avatar, - walletAddress: user.walletAddress, - isActive: user.isActive, - createdAt: user.createdAt, - updatedAt: user.updatedAt, - }) + res.json(this.toUserResponse(user)) } catch { res.status(500).json({ error: 'Internal server error' }) } @@ -92,19 +90,7 @@ export class UserController { } const data = req.body as UpdateUserData const user = await this.updateUserProfile(userId, data) - res.json({ - id: user.id, - email: user.email, - username: user.username, - firstName: user.firstName, - lastName: user.lastName, - bio: user.bio, - avatar: user.avatar, - walletAddress: user.walletAddress, - isActive: user.isActive, - createdAt: user.createdAt, - updatedAt: user.updatedAt, - }) + res.json(this.toUserResponse(user)) } catch { res.status(500).json({ error: 'Internal server error' }) } @@ -124,9 +110,6 @@ export class UserController { const publicInfo: PublicUserInfo = { id: user.id, username: user.username, - firstName: user.firstName, - lastName: user.lastName, - avatar: user.avatar, role: user.role, createdAt: user.createdAt, } @@ -147,7 +130,6 @@ export class UserController { if (!user) { res.status(404).json({ error: 'User not found' }) - return } @@ -155,7 +137,6 @@ export class UserController { if (!isCurrentPasswordValid) { res.status(400).json({ error: 'Current password is incorrect' }) - return } @@ -199,6 +180,8 @@ export class UserController { * description: Invalid Stellar wallet address * 401: * description: Unauthorized + * 409: + * description: Wallet address already in use * 500: * description: Internal server error */ @@ -218,87 +201,69 @@ export class UserController { return } const user = await this.updateUserWallet(userId, walletAddress) - res.json({ - id: user.id, - email: user.email, - username: user.username, - firstName: (user as any).firstName, - lastName: (user as any).lastName, - bio: (user as any).bio, - avatar: (user as any).avatar, - walletAddress: user.walletAddress, - isActive: user.isActive, - createdAt: user.createdAt, - updatedAt: user.updatedAt, - }) - } catch { + res.json(this.toUserResponse(user)) + } catch (error) { + if (error instanceof WalletConflictError) { + res.status(409).json({ error: error.message }) + + return + } res.status(500).json({ error: 'Internal server error' }) } } - private async findUserById (id: string): Promise { - const mockUser: User = { - id, - email: 'test@example.com', - username: 'testuser', - firstName: 'Test', - lastName: 'User', - bio: 'Test bio', - avatar: 'https://example.com/avatar.jpg', - walletAddress: 'GABC123456789012345678901234567890123456789012345678901234567890', - isActive: true, - role: 'LEARNER' as any, - status: 'active' as any, - createdAt: new Date(), - updatedAt: new Date(), - } - - return mockUser + private findUserById (id: string): Promise { + return prisma.user.findUnique({ where: { id } }) } - private async updateUserProfile (id: string, data: UpdateUserData): Promise { - const mockUser: User = { - id, - email: 'test@example.com', - username: data.username || 'testuser', - firstName: data.firstName, - lastName: data.lastName, - bio: data.bio, - avatar: data.avatar, - walletAddress: 'GABC123456789012345678901234567890123456789012345678901234567890', - isActive: true, - role: 'LEARNER' as any, - status: 'active' as any, - createdAt: new Date(), - updatedAt: new Date(), - } - - return mockUser + private updateUserProfile (id: string, data: UpdateUserData): Promise { + return prisma.user.update({ + where: { id }, + data: { username: data.username }, + }) } - private async validatePassword (_user: User, _password: string): Promise { - return false + private validatePassword (user: User, password: string): Promise { + return bcrypt.compare(password, user.password) } - private async updateUserPassword (_id: string, _newPassword: string): Promise { - throw new Error('Not implemented') + private async updateUserPassword (id: string, newPassword: string): Promise { + const hashed = await bcrypt.hash(newPassword, 10) + + await prisma.user.update({ + where: { id }, + data: { password: hashed }, + }) } private async updateUserWallet (id: string, walletAddress: string): Promise { - const mockUser: User = { - id, - email: 'test@example.com', - username: 'testuser', - walletAddress, - isActive: true, - createdAt: new Date(), - updatedAt: new Date(), - } as never - - return mockUser + try { + return await prisma.user.update({ + where: { id }, + data: { walletAddress }, + }) + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') { + throw new WalletConflictError() + } + + throw error + } } private isValidStellarAddress (address: string): boolean { return /^G[A-Z0-9]{50,55}$/.test(address) } + + private toUserResponse (user: User) { + return { + id: user.id, + email: user.email, + username: user.username, + role: user.role, + walletAddress: user.walletAddress, + createdAt: user.createdAt, + updatedAt: user.updatedAt, + } + } } diff --git a/src/docs/schemas.ts b/src/docs/schemas.ts index 8f9e537e..ccc5910b 100644 --- a/src/docs/schemas.ts +++ b/src/docs/schemas.ts @@ -13,19 +13,8 @@ * format: email * username: * type: string - * firstName: - * type: string - * lastName: - * type: string - * bio: - * type: string - * avatar: - * type: string - * format: url * walletAddress: * type: string - * isActive: - * type: boolean * role: * type: string * enum: [LEARNER, EMPLOYER, ADMIN] @@ -41,15 +30,6 @@ * properties: * username: * type: string - * firstName: - * type: string - * lastName: - * type: string - * bio: - * type: string - * avatar: - * type: string - * format: url * * RegisterInput: * type: object diff --git a/src/middleware/validation.middleware.ts b/src/middleware/validation.middleware.ts index 599924e4..af87b521 100644 --- a/src/middleware/validation.middleware.ts +++ b/src/middleware/validation.middleware.ts @@ -116,10 +116,6 @@ export const validate = (schemas: ValidationSchemas) => { 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(), }) }) diff --git a/src/types/user.types.ts b/src/types/user.types.ts index 2ba9321b..c94ad777 100644 --- a/src/types/user.types.ts +++ b/src/types/user.types.ts @@ -6,87 +6,34 @@ export enum UserRole { INSTRUCTOR = 'instructor', } -export enum UserStatus { - ACTIVE = 'active', - INACTIVE = 'inactive', - SUSPENDED = 'suspended', - PENDING_VERIFICATION = 'pending_verification', -} - // ── Core models ──────────────────────────────────────────── export interface User { id: string; email: string; username: string; - firstName?: string; - lastName?: string; - bio?: string; - avatar?: string; - walletAddress?: string; - role: UserRole; - status: UserStatus; - isActive: boolean; + password: string; + role: string; + walletAddress: string | null; createdAt: Date; updatedAt: Date; - lastLoginAt?: Date; + lastLoginAt: Date | null; } export interface PublicUserInfo { id: string; username: string; - firstName?: string; - lastName?: string; - avatar?: string; - role: UserRole; + role: string; createdAt: Date; } -export interface UserProfile extends User { - totalCredentials: number; - totalPoints: number; - completedModules: number; -} - // ── Request types ────────────────────────────────────────── -export interface CreateUserData { - email: string; - username: string; - password: string; - firstName?: string; - lastName?: string; - role?: UserRole; -} - export interface UpdateUserData { username?: string; - firstName?: string; - lastName?: string; - bio?: string; - avatar?: string; } export interface ChangePasswordData { currentPassword: string; newPassword: string; } - -export interface UpdateWalletData { - walletAddress: string; -} - -export interface UpdateUserRoleData { - role: UserRole; -} - -export interface UpdateUserStatusData { - status: UserStatus; -} - -export interface UserFilterParams { - role?: UserRole; - status?: UserStatus; - search?: string; - isActive?: boolean; -} diff --git a/tests/unit/validation.middleware.test.ts b/tests/unit/validation.middleware.test.ts index d7a34d04..3fba43ea 100644 --- a/tests/unit/validation.middleware.test.ts +++ b/tests/unit/validation.middleware.test.ts @@ -200,13 +200,7 @@ describe('validate', () => { describe('validateProfileUpdate', () => { it('calls next() for a valid update payload', () => { - const { req, res, next } = makeMocks({ - username: 'valid_user', - firstName: 'John', - lastName: 'Doe', - bio: 'Hello world', - avatar: 'https://example.com/avatar.jpg', - }) + const { req, res, next } = makeMocks({ username: 'valid_user' }) validateProfileUpdate(req as Request, res as Response, next) @@ -259,65 +253,22 @@ describe('validateProfileUpdate', () => { }) }) - it('returns 400 when firstName exceeds 50 characters', () => { - const { req, res, next } = makeMocks({ firstName: 'A'.repeat(51) }) - - validateProfileUpdate(req as Request, res as Response, next) - - expect(res.status).toHaveBeenCalledWith(400) - 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', () => { - const { req, res, next } = makeMocks({ lastName: 'B'.repeat(51) }) - - validateProfileUpdate(req as Request, res as Response, next) - - expect(res.status).toHaveBeenCalledWith(400) - 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', () => { - const { req, res, next } = makeMocks({ bio: 'x'.repeat(501) }) - - validateProfileUpdate(req as Request, res as Response, next) - - expect(res.status).toHaveBeenCalledWith(400) - 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', () => { - const { req, res, next } = makeMocks({ avatar: 'not-a-url' }) - - validateProfileUpdate(req as Request, res as Response, next) - - expect(res.status).toHaveBeenCalledWith(400) - expect(res.json).toHaveBeenCalledWith({ - message: 'Validation failed', - errors: { body: ['Invalid URL format'] } - }) - }) - - it('returns multiple errors when multiple fields are invalid', () => { + it('ignores unsupported fields (firstName, lastName, bio, avatar)', () => { const { req, res, next } = makeMocks({ - username: 'ab', - bio: 'x'.repeat(501), + username: 'valid_user', + firstName: 'John', + lastName: 'Doe', + bio: 'Hello world', + avatar: 'https://example.com/avatar.jpg', }) validateProfileUpdate(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.body.length).toBeGreaterThanOrEqual(2) + expect(next).toHaveBeenCalledOnce() + expect((req as any).body.firstName).toBeUndefined() + expect((req as any).body.lastName).toBeUndefined() + expect((req as any).body.bio).toBeUndefined() + expect((req as any).body.avatar).toBeUndefined() }) }) diff --git a/tests/user.controller.test.ts b/tests/user.controller.test.ts index 6137cc5d..c1b045b8 100644 --- a/tests/user.controller.test.ts +++ b/tests/user.controller.test.ts @@ -1,282 +1,231 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' +import { Prisma } from '@prisma/client' +import bcrypt from 'bcryptjs' import { Request, Response } from 'express' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import prisma from '../src/config/database' import { UserController } from '../src/controllers/user.controller' -import { User } from '../src/types/user.types' -interface AuthRequest extends Request { - user?: { - id: string; - email: string; - }; +vi.mock('../src/config/database', () => ({ + default: { + user: { + findUnique: vi.fn(), + update: vi.fn(), + }, + }, +})) + +vi.mock('bcryptjs', () => ({ + default: { + compare: vi.fn(), + hash: vi.fn(), + }, +})) + +function createResponse() { + const response: Partial = {} + response.status = vi.fn().mockReturnValue(response) + response.json = vi.fn().mockReturnValue(response) + + return response as Response } +const persistedUser = { + id: '1', + email: 'user@example.com', + username: 'testuser', + password: 'hashed_password', + role: 'LEARNER', + walletAddress: null, + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-02T00:00:00Z'), + lastLoginAt: null, +} + +const validWalletAddress = 'GABC1234567890123456789012345678901234567890123456789' + describe('UserController', () => { - let userController: UserController - let mockRequest: Partial - let mockResponse: Partial + let controller: UserController beforeEach(() => { - userController = new UserController() - mockRequest = {} - mockResponse = { - json: vi.fn(), - status: vi.fn().mockReturnThis(), - } + vi.clearAllMocks() + controller = new UserController() }) describe('getCurrentUser', () => { - it('should return current user profile', async () => { - const mockUser: User = { + it('returns the persisted row without leaking the password', async () => { + ;(prisma.user.findUnique as any).mockResolvedValue(persistedUser) + + const req = { user: { id: '1' } } as unknown as Request + const res = createResponse() + + await controller.getCurrentUser(req, res) + + expect(prisma.user.findUnique).toHaveBeenCalledWith({ where: { id: '1' } }) + expect(res.json).toHaveBeenCalledWith({ id: '1', - email: 'test@example.com', + email: 'user@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, + role: 'LEARNER', + walletAddress: null, + createdAt: persistedUser.createdAt, + updatedAt: persistedUser.updatedAt, }) + expect(JSON.stringify((res.json as any).mock.calls[0][0])).not.toContain('hashed_password') }) - 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) + it('returns 404 when the user does not exist', async () => { + ;(prisma.user.findUnique as any).mockResolvedValue(null) + + const req = { user: { id: 'missing' } } as unknown as Request + const res = createResponse() - await userController.getCurrentUser(mockRequest as Request, mockResponse as Response) + await controller.getCurrentUser(req, res) - expect(mockResponse.status).toHaveBeenCalledWith(404) - expect(mockResponse.json).toHaveBeenCalledWith({ error: 'User not found' }) + expect(res.status).toHaveBeenCalledWith(404) + expect(res.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, + it('persists the username via prisma.user.update and returns the row', async () => { + const updated = { ...persistedUser, username: 'updateduser' } + ;(prisma.user.update as any).mockResolvedValue(updated) + + const req = { user: { id: '1' }, body: { username: 'updateduser' } } as unknown as Request + const res = createResponse() + + await controller.updateProfile(req, res) + + expect(prisma.user.update).toHaveBeenCalledWith({ + where: { id: '1' }, + data: { username: 'updateduser' }, }) + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ username: 'updateduser' })) }) }) describe('getUserById', () => { - it('should return public user info', async () => { - const mockUser: User = { + it('returns public user info', async () => { + ;(prisma.user.findUnique as any).mockResolvedValue(persistedUser) + + const req = { params: { id: '1' } } as unknown as Request + const res = createResponse() + + await controller.getUserById(req, res) + + expect(res.json).toHaveBeenCalledWith({ 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, + role: 'LEARNER', + createdAt: persistedUser.createdAt, }) }) - it('should return 404 if user not found', async () => { - mockRequest.params = { id: '1' } - - vi.spyOn(userController as any, 'findUserById').mockResolvedValue(null) + it('returns 404 when the user does not exist', async () => { + ;(prisma.user.findUnique as any).mockResolvedValue(null) - await userController.getUserById(mockRequest as Request, mockResponse as Response) + const req = { params: { id: 'missing' } } as unknown as Request + const res = createResponse() - expect(mockResponse.status).toHaveBeenCalledWith(404) - expect(mockResponse.json).toHaveBeenCalledWith({ error: 'User not found' }) + await controller.getUserById(req, res) + + expect(res.status).toHaveBeenCalledWith(404) }) }) 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(), - } + it('verifies the current password, hashes, and persists the new password', async () => { + ;(prisma.user.findUnique as any).mockResolvedValue(persistedUser) + ;(bcrypt.compare as any).mockResolvedValue(true) + ;(bcrypt.hash as any).mockResolvedValue('new_hash') + + const req = { + user: { id: '1' }, + body: { currentPassword: 'OldPassword123!', newPassword: 'NewPassword123!' }, + } as unknown as Request + const res = createResponse() + + await controller.changePassword(req, res) + + expect(bcrypt.compare).toHaveBeenCalledWith('OldPassword123!', 'hashed_password') + expect(bcrypt.hash).toHaveBeenCalledWith('NewPassword123!', 10) + expect(prisma.user.update).toHaveBeenCalledWith({ + where: { id: '1' }, + data: { password: 'new_hash' }, + }) + expect(res.json).toHaveBeenCalledWith({ message: 'Password updated successfully' }) + }) - mockRequest.user = { id: '1', email: 'test@example.com' } - mockRequest.body = { - currentPassword: 'oldpassword', - newPassword: 'NewPassword123!', - } + it('returns 400 and does not persist when the current password is incorrect', async () => { + ;(prisma.user.findUnique as any).mockResolvedValue(persistedUser) + ;(bcrypt.compare as any).mockResolvedValue(false) - vi.spyOn(userController as any, 'findUserById').mockResolvedValue(mockUser) - vi.spyOn(userController as any, 'validatePassword').mockResolvedValue(true) - vi.spyOn(userController as any, 'updateUserPassword').mockResolvedValue(undefined) + const req = { + user: { id: '1' }, + body: { currentPassword: 'WrongPassword123!', newPassword: 'NewPassword123!' }, + } as unknown as Request + const res = createResponse() - await userController.changePassword(mockRequest as Request, mockResponse as Response) + await controller.changePassword(req, res) - expect(mockResponse.json).toHaveBeenCalledWith({ message: 'Password updated successfully' }) + expect(res.status).toHaveBeenCalledWith(400) + expect(res.json).toHaveBeenCalledWith({ error: 'Current password is incorrect' }) + expect(prisma.user.update).not.toHaveBeenCalled() }) + }) - 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!', - } + describe('updateWalletAddress', () => { + it('persists the wallet address and returns the row', async () => { + const updated = { ...persistedUser, walletAddress: validWalletAddress } + ;(prisma.user.update as any).mockResolvedValue(updated) - vi.spyOn(userController as any, 'findUserById').mockResolvedValue(mockUser) - vi.spyOn(userController as any, 'validatePassword').mockResolvedValue(false) + const req = { user: { id: '1' }, body: { walletAddress: validWalletAddress } } as unknown as Request + const res = createResponse() - await userController.changePassword(mockRequest as Request, mockResponse as Response) + await controller.updateWalletAddress(req, res) - expect(mockResponse.status).toHaveBeenCalledWith(400) - expect(mockResponse.json).toHaveBeenCalledWith({ error: 'Current password is incorrect' }) + expect(prisma.user.update).toHaveBeenCalledWith({ + where: { id: '1' }, + data: { walletAddress: validWalletAddress }, + }) + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ walletAddress: validWalletAddress })) }) - }) - 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('returns 409 on a duplicate wallet address', async () => { + ;(prisma.user.update as any).mockRejectedValue( + new Prisma.PrismaClientKnownRequestError('Unique constraint failed', { + code: 'P2002', + clientVersion: '7.4.2', + }), + ) + + const req = { user: { id: '1' }, body: { walletAddress: validWalletAddress } } as unknown as Request + const res = createResponse() + + await controller.updateWalletAddress(req, res) + + expect(res.status).toHaveBeenCalledWith(409) + expect(res.json).toHaveBeenCalledWith({ error: 'Wallet address is already in use' }) }) - it('should return 400 for invalid wallet address', async () => { - mockRequest.user = { id: '1', email: 'test@example.com' } - mockRequest.body = { - walletAddress: 'invalid-address', - } + it('returns 400 for an invalid wallet address', async () => { + const req = { user: { id: '1' }, body: { walletAddress: 'invalid-address' } } as unknown as Request + const res = createResponse() - await userController.updateWalletAddress(mockRequest as Request, mockResponse as Response) + await controller.updateWalletAddress(req, res) - expect(mockResponse.status).toHaveBeenCalledWith(400) - expect(mockResponse.json).toHaveBeenCalledWith({ error: 'Invalid Stellar wallet address' }) + expect(res.status).toHaveBeenCalledWith(400) + expect(res.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('validates a correct Stellar address', () => { + expect((controller as any).isValidStellarAddress(validWalletAddress)).toBe(true) }) - it('should reject address with wrong length', () => { - const shortAddress = 'GABC123' - expect((userController as any).isValidStellarAddress(shortAddress)).toBe(false) + it('rejects an invalid Stellar address', () => { + expect((controller as any).isValidStellarAddress('invalid-address')).toBe(false) }) }) })