diff --git a/README.md b/README.md index 12185fd1..060c8a00 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ +[![codecov](https://codecov.io/gh/w3bdesign/frisorsalong-booking/graph/badge.svg?token=YDY1N2NMWA)](https://codecov.io/gh/w3bdesign/frisorsalong-booking) + # Hair Salon Booking System ## This is still Work In Progress (WIP)! diff --git a/backend/jest.config.js b/backend/jest.config.js index e6860621..e05678e6 100644 --- a/backend/jest.config.js +++ b/backend/jest.config.js @@ -5,10 +5,25 @@ module.exports = { transform: { '^.+\\.(t|j)s$': 'ts-jest', }, - collectCoverageFrom: ['**/*.(t|j)s'], + collectCoverageFrom: [ + '**/*.(t|j)s', + '!**/*.spec.ts', + '!**/*.test.ts', + '!**/node_modules/**', + '!**/dist/**', + '!**/coverage/**', + ], coverageDirectory: '../coverage', testEnvironment: 'node', moduleNameMapper: { '^src/(.*)$': '/$1', }, + coverageThreshold: { + global: { + branches: 80, + functions: 80, + lines: 80, + statements: 80 + } + } }; diff --git a/backend/src/auth/guards/roles.guard.spec.ts b/backend/src/auth/guards/roles.guard.spec.ts new file mode 100644 index 00000000..3a732a87 --- /dev/null +++ b/backend/src/auth/guards/roles.guard.spec.ts @@ -0,0 +1,108 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { Reflector } from '@nestjs/core'; +import { ExecutionContext } from '@nestjs/common'; +import { RolesGuard } from './roles.guard'; +import { UserRole } from '../../users/entities/user.entity'; + +describe('RolesGuard', () => { + let guard: RolesGuard; + let reflector: Reflector; + + const mockReflector = { + getAllAndOverride: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + RolesGuard, + { + provide: Reflector, + useValue: mockReflector, + }, + ], + }).compile(); + + guard = module.get(RolesGuard); + reflector = module.get(Reflector); + }); + + it('should be defined', () => { + expect(guard).toBeDefined(); + }); + + describe('canActivate', () => { + let mockExecutionContext: ExecutionContext; + + beforeEach(() => { + mockExecutionContext = { + getHandler: jest.fn(), + getClass: jest.fn(), + switchToHttp: jest.fn().mockReturnValue({ + getRequest: jest.fn().mockReturnValue({ + user: { + role: UserRole.CUSTOMER, + }, + }), + }), + } as unknown as ExecutionContext; + }); + + it('should allow access when no roles are required', () => { + mockReflector.getAllAndOverride.mockReturnValue(null); + + const result = guard.canActivate(mockExecutionContext); + + expect(result).toBe(true); + expect(reflector.getAllAndOverride).toHaveBeenCalledWith('roles', [ + mockExecutionContext.getHandler(), + mockExecutionContext.getClass(), + ]); + }); + + it('should allow access when user has required role', () => { + mockReflector.getAllAndOverride.mockReturnValue([UserRole.CUSTOMER]); + + const result = guard.canActivate(mockExecutionContext); + + expect(result).toBe(true); + }); + + it('should deny access when user does not have required role', () => { + mockReflector.getAllAndOverride.mockReturnValue([UserRole.ADMIN]); + + const result = guard.canActivate(mockExecutionContext); + + expect(result).toBe(false); + }); + + it('should allow access when user has one of multiple required roles', () => { + mockReflector.getAllAndOverride.mockReturnValue([ + UserRole.ADMIN, + UserRole.CUSTOMER, + ]); + + const result = guard.canActivate(mockExecutionContext); + + expect(result).toBe(true); + }); + + it('should handle roles defined at both handler and class level', () => { + mockReflector.getAllAndOverride.mockReturnValue([UserRole.CUSTOMER]); + + const mockContext = { + ...mockExecutionContext, + getHandler: jest.fn(), + getClass: jest.fn(), + }; + + const result = guard.canActivate(mockContext as ExecutionContext); + + expect(result).toBe(true); + expect(reflector.getAllAndOverride).toHaveBeenCalledWith('roles', [ + mockContext.getHandler(), + mockContext.getClass(), + ]); + }); + }); +}); diff --git a/backend/src/database/migrations/1731981975581-InitialMigration.spec.ts b/backend/src/database/migrations/1731981975581-InitialMigration.spec.ts new file mode 100644 index 00000000..3ff0207a --- /dev/null +++ b/backend/src/database/migrations/1731981975581-InitialMigration.spec.ts @@ -0,0 +1,154 @@ +import { QueryRunner } from 'typeorm'; +import { InitialMigration1731981975581 } from './1731981975581-InitialMigration'; + +describe('InitialMigration1731981975581', () => { + let migration: InitialMigration1731981975581; + let queryRunner: QueryRunner; + + beforeEach(() => { + migration = new InitialMigration1731981975581(); + queryRunner = { + query: jest.fn(), + } as unknown as QueryRunner; + }); + + it('should have correct name', () => { + expect(migration.name).toBe('InitialMigration1731981975581'); + }); + + describe('up', () => { + it('should create user role enum', async () => { + await migration.up(queryRunner); + + expect(queryRunner.query).toHaveBeenCalledWith( + expect.stringContaining('CREATE TYPE "public"."users_role_enum"'), + ); + }); + + it('should create users table', async () => { + await migration.up(queryRunner); + + expect(queryRunner.query).toHaveBeenCalledWith( + expect.stringContaining('CREATE TABLE "users"'), + ); + }); + + it('should create employees table', async () => { + await migration.up(queryRunner); + + expect(queryRunner.query).toHaveBeenCalledWith( + expect.stringContaining('CREATE TABLE "employees"'), + ); + }); + + it('should create services table', async () => { + await migration.up(queryRunner); + + expect(queryRunner.query).toHaveBeenCalledWith( + expect.stringContaining('CREATE TABLE "services"'), + ); + }); + + it('should create booking status enum', async () => { + await migration.up(queryRunner); + + expect(queryRunner.query).toHaveBeenCalledWith( + expect.stringContaining('CREATE TYPE "public"."bookings_status_enum"'), + ); + }); + + it('should create bookings table', async () => { + await migration.up(queryRunner); + + expect(queryRunner.query).toHaveBeenCalledWith( + expect.stringContaining('CREATE TABLE "bookings"'), + ); + }); + + it('should create employee_services table', async () => { + await migration.up(queryRunner); + + expect(queryRunner.query).toHaveBeenCalledWith( + expect.stringContaining('CREATE TABLE "employee_services"'), + ); + }); + + it('should create indexes', async () => { + await migration.up(queryRunner); + + expect(queryRunner.query).toHaveBeenCalledWith( + expect.stringContaining('CREATE INDEX'), + ); + }); + + it('should create foreign key constraints', async () => { + await migration.up(queryRunner); + + expect(queryRunner.query).toHaveBeenCalledWith( + expect.stringContaining('ALTER TABLE'), + ); + expect(queryRunner.query).toHaveBeenCalledWith( + expect.stringContaining('FOREIGN KEY'), + ); + }); + }); + + describe('down', () => { + it('should drop foreign key constraints', async () => { + await migration.down(queryRunner); + + expect(queryRunner.query).toHaveBeenCalledWith( + expect.stringContaining('DROP CONSTRAINT'), + ); + }); + + it('should drop indexes', async () => { + await migration.down(queryRunner); + + expect(queryRunner.query).toHaveBeenCalledWith( + expect.stringContaining('DROP INDEX'), + ); + }); + + it('should drop tables in correct order', async () => { + await migration.down(queryRunner); + + const calls = (queryRunner.query as jest.Mock).mock.calls.map( + call => call[0], + ); + + // Verify drop order: employee_services -> bookings -> services -> employees -> users + const employeeServicesIndex = calls.findIndex(call => + call.includes('DROP TABLE "employee_services"'), + ); + const bookingsIndex = calls.findIndex(call => + call.includes('DROP TABLE "bookings"'), + ); + const servicesIndex = calls.findIndex(call => + call.includes('DROP TABLE "services"'), + ); + const employeesIndex = calls.findIndex(call => + call.includes('DROP TABLE "employees"'), + ); + const usersIndex = calls.findIndex(call => + call.includes('DROP TABLE "users"'), + ); + + expect(employeeServicesIndex).toBeLessThan(bookingsIndex); + expect(bookingsIndex).toBeLessThan(servicesIndex); + expect(servicesIndex).toBeLessThan(employeesIndex); + expect(employeesIndex).toBeLessThan(usersIndex); + }); + + it('should drop enums', async () => { + await migration.down(queryRunner); + + expect(queryRunner.query).toHaveBeenCalledWith( + expect.stringContaining('DROP TYPE "public"."users_role_enum"'), + ); + expect(queryRunner.query).toHaveBeenCalledWith( + expect.stringContaining('DROP TYPE "public"."bookings_status_enum"'), + ); + }); + }); +}); diff --git a/backend/src/database/migrations/1731981975582-CreateBookingSystem.spec.ts b/backend/src/database/migrations/1731981975582-CreateBookingSystem.spec.ts new file mode 100644 index 00000000..fd662f14 --- /dev/null +++ b/backend/src/database/migrations/1731981975582-CreateBookingSystem.spec.ts @@ -0,0 +1,137 @@ +import { QueryRunner } from 'typeorm'; +import { CreateBookingSystem1731981975582 } from './1731981975582-CreateBookingSystem'; + +describe('CreateBookingSystem1731981975582', () => { + let migration: CreateBookingSystem1731981975582; + let queryRunner: QueryRunner; + + beforeEach(() => { + migration = new CreateBookingSystem1731981975582(); + queryRunner = { + query: jest.fn(), + } as unknown as QueryRunner; + }); + + it('should have correct name', () => { + expect(migration.name).toBe('CreateBookingSystem1731981975582'); + }); + + describe('up', () => { + it('should create services table with correct schema', async () => { + await migration.up(queryRunner); + + expect(queryRunner.query).toHaveBeenCalledWith( + expect.stringMatching(/CREATE TABLE[\s\S]*services[\s\S]*id[\s\S]*name[\s\S]*description[\s\S]*duration[\s\S]*price/), + ); + }); + + it('should create employees table with correct schema and foreign key', async () => { + await migration.up(queryRunner); + + expect(queryRunner.query).toHaveBeenCalledWith( + expect.stringMatching(/CREATE TABLE[\s\S]*employees[\s\S]*CONSTRAINT[\s\S]*FOREIGN KEY[\s\S]*REFERENCES[\s\S]*users/), + ); + }); + + it('should create employee_services junction table with correct constraints', async () => { + await migration.up(queryRunner); + + expect(queryRunner.query).toHaveBeenCalledWith( + expect.stringMatching(/CREATE TABLE[\s\S]*employee_services[\s\S]*PRIMARY KEY[\s\S]*FOREIGN KEY/), + ); + }); + + it('should create bookings table with correct schema and foreign keys', async () => { + await migration.up(queryRunner); + + const bookingsCall = (queryRunner.query as jest.Mock).mock.calls.find(call => + call[0].includes('CREATE TABLE "bookings"'), + ); + + expect(bookingsCall[0]).toMatch(/CONSTRAINT[\s\S]*fk_booking_customer[\s\S]*FOREIGN KEY[\s\S]*REFERENCES[\s\S]*users/); + expect(bookingsCall[0]).toMatch(/CONSTRAINT[\s\S]*fk_booking_employee[\s\S]*FOREIGN KEY[\s\S]*REFERENCES[\s\S]*employees/); + expect(bookingsCall[0]).toMatch(/CONSTRAINT[\s\S]*fk_booking_service[\s\S]*FOREIGN KEY[\s\S]*REFERENCES[\s\S]*services/); + }); + + it('should create all required indexes', async () => { + await migration.up(queryRunner); + + const expectedIndexes = [ + 'idx_bookings_customer', + 'idx_bookings_employee', + 'idx_bookings_service', + 'idx_bookings_start_time', + 'idx_bookings_status', + ]; + + expectedIndexes.forEach(indexName => { + expect(queryRunner.query).toHaveBeenCalledWith( + expect.stringContaining(`CREATE INDEX "${indexName}"`), + ); + }); + }); + }); + + describe('down', () => { + it('should drop all indexes in correct order', async () => { + await migration.down(queryRunner); + + const expectedIndexes = [ + 'idx_bookings_status', + 'idx_bookings_start_time', + 'idx_bookings_service', + 'idx_bookings_employee', + 'idx_bookings_customer', + ]; + + const calls = (queryRunner.query as jest.Mock).mock.calls.map( + call => call[0], + ); + + expectedIndexes.forEach((indexName, i) => { + const dropIndex = calls.findIndex(call => + call.includes(`DROP INDEX "${indexName}"`) + ); + expect(dropIndex).toBe(i); + }); + }); + + it('should drop tables in correct order', async () => { + await migration.down(queryRunner); + + const calls = (queryRunner.query as jest.Mock).mock.calls.map( + call => call[0], + ); + + // Verify drop order: bookings -> employee_services -> employees -> services + const bookingsIndex = calls.findIndex(call => + call.includes('DROP TABLE "bookings"'), + ); + const employeeServicesIndex = calls.findIndex(call => + call.includes('DROP TABLE "employee_services"'), + ); + const employeesIndex = calls.findIndex(call => + call.includes('DROP TABLE "employees"'), + ); + const servicesIndex = calls.findIndex(call => + call.includes('DROP TABLE "services"'), + ); + + expect(bookingsIndex).toBeLessThan(employeeServicesIndex); + expect(employeeServicesIndex).toBeLessThan(employeesIndex); + expect(employeesIndex).toBeLessThan(servicesIndex); + }); + + it('should drop all tables', async () => { + await migration.down(queryRunner); + + const expectedTables = ['bookings', 'employee_services', 'employees', 'services']; + + expectedTables.forEach(tableName => { + expect(queryRunner.query).toHaveBeenCalledWith( + expect.stringContaining(`DROP TABLE "${tableName}"`), + ); + }); + }); + }); +}); diff --git a/backend/src/database/seeds/create-admin-user.seed.spec.ts b/backend/src/database/seeds/create-admin-user.seed.spec.ts new file mode 100644 index 00000000..a4bcb8f7 --- /dev/null +++ b/backend/src/database/seeds/create-admin-user.seed.spec.ts @@ -0,0 +1,160 @@ +import { DataSource, Repository, FindOneOptions } from 'typeorm'; +import { User, UserRole } from '../../users/entities/user.entity'; +import { createAdminUser } from './create-admin-user.seed'; +import * as bcrypt from 'bcrypt'; + +jest.mock('bcrypt'); + +describe('createAdminUser', () => { + let mockDataSource: Partial; + let mockUserRepository: Partial>; + const originalEnv = process.env; + + beforeEach(() => { + // Mock repository methods + mockUserRepository = { + findOne: jest.fn() as jest.Mock, + create: jest.fn() as jest.Mock, + save: jest.fn() as jest.Mock, + }; + + // Mock DataSource + mockDataSource = { + getRepository: jest.fn().mockReturnValue(mockUserRepository), + }; + + // Mock bcrypt + (bcrypt.hash as jest.Mock).mockResolvedValue('hashed-password'); + + // Setup test environment variables + process.env = { + ...originalEnv, + ADMIN_EMAIL: 'admin@example.com', + ADMIN_PASSWORD: 'admin123', + }; + + // Mock console methods + jest.spyOn(console, 'log').mockImplementation(() => {}); + jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + process.env = originalEnv; + jest.clearAllMocks(); + }); + + it('should create admin user when it does not exist', async () => { + // Mock that admin doesn't exist + (mockUserRepository.findOne as jest.Mock).mockResolvedValue(null); + + // Mock created user + const mockCreatedUser = { + email: 'admin@example.com', + password: 'hashed-password', + firstName: 'Admin', + lastName: 'User', + role: UserRole.ADMIN, + }; + (mockUserRepository.create as jest.Mock).mockReturnValue(mockCreatedUser); + (mockUserRepository.save as jest.Mock).mockResolvedValue(mockCreatedUser); + + await createAdminUser(mockDataSource as DataSource); + + // Verify admin user was searched for + expect(mockUserRepository.findOne).toHaveBeenCalledWith({ + where: { email: 'admin@example.com' }, + }); + + // Verify password was hashed + expect(bcrypt.hash).toHaveBeenCalledWith('admin123', 10); + + // Verify user was created with correct data + expect(mockUserRepository.create).toHaveBeenCalledWith({ + email: 'admin@example.com', + password: 'hashed-password', + firstName: 'Admin', + lastName: 'User', + role: UserRole.ADMIN, + }); + + // Verify user was saved + expect(mockUserRepository.save).toHaveBeenCalledWith(mockCreatedUser); + + // Verify success message was logged + expect(console.log).toHaveBeenCalledWith('Admin user created successfully'); + }); + + it('should not create admin user when it already exists', async () => { + // Mock that admin already exists + const existingAdmin = { + email: 'admin@example.com', + role: UserRole.ADMIN, + }; + (mockUserRepository.findOne as jest.Mock).mockResolvedValue(existingAdmin); + + await createAdminUser(mockDataSource as DataSource); + + // Verify admin user was searched for + expect(mockUserRepository.findOne).toHaveBeenCalledWith({ + where: { email: 'admin@example.com' }, + }); + + // Verify no creation attempts were made + expect(mockUserRepository.create).not.toHaveBeenCalled(); + expect(mockUserRepository.save).not.toHaveBeenCalled(); + + // Verify appropriate message was logged + expect(console.log).toHaveBeenCalledWith('Admin user already exists'); + }); + + it('should throw error when admin email is missing', async () => { + delete process.env.ADMIN_EMAIL; + + await expect(createAdminUser(mockDataSource as DataSource)).rejects.toThrow( + 'Admin email and password must be set in environment variables', + ); + + // Verify no database operations were attempted + expect(mockUserRepository.findOne).not.toHaveBeenCalled(); + expect(mockUserRepository.create).not.toHaveBeenCalled(); + expect(mockUserRepository.save).not.toHaveBeenCalled(); + }); + + it('should throw error when admin password is missing', async () => { + delete process.env.ADMIN_PASSWORD; + + await expect(createAdminUser(mockDataSource as DataSource)).rejects.toThrow( + 'Admin email and password must be set in environment variables', + ); + + // Verify no database operations were attempted + expect(mockUserRepository.findOne).not.toHaveBeenCalled(); + expect(mockUserRepository.create).not.toHaveBeenCalled(); + expect(mockUserRepository.save).not.toHaveBeenCalled(); + }); + + it('should handle database errors', async () => { + // Mock database error + const dbError = new Error('Database connection failed'); + (mockUserRepository.findOne as jest.Mock).mockRejectedValue(dbError); + + await expect(createAdminUser(mockDataSource as DataSource)).rejects.toThrow(dbError); + + // Verify error was logged + expect(console.error).toHaveBeenCalledWith('Error creating admin user:', dbError); + }); + + it('should handle password hashing errors', async () => { + // Mock that admin doesn't exist + (mockUserRepository.findOne as jest.Mock).mockResolvedValue(null); + + // Mock bcrypt error + const hashError = new Error('Hashing failed'); + (bcrypt.hash as jest.Mock).mockRejectedValue(hashError); + + await expect(createAdminUser(mockDataSource as DataSource)).rejects.toThrow(hashError); + + // Verify error was logged + expect(console.error).toHaveBeenCalledWith('Error creating admin user:', hashError); + }); +}); diff --git a/backend/src/database/seeds/create-initial-data.seed.spec.ts b/backend/src/database/seeds/create-initial-data.seed.spec.ts new file mode 100644 index 00000000..4c44fd31 --- /dev/null +++ b/backend/src/database/seeds/create-initial-data.seed.spec.ts @@ -0,0 +1,226 @@ +import { DataSource, Repository, InsertQueryBuilder, EntityTarget } from 'typeorm'; +import { User, UserRole } from '../../users/entities/user.entity'; +import { Employee } from '../../employees/entities/employee.entity'; +import { Service } from '../../services/entities/service.entity'; +import { createInitialData } from './create-initial-data.seed'; +import * as bcrypt from 'bcrypt'; + +jest.mock('bcrypt'); + +describe('createInitialData', () => { + let mockDataSource: Partial; + let mockUserRepository: Partial>; + let mockEmployeeRepository: Partial>; + let mockServiceRepository: Partial>; + let mockQueryBuilder: Partial>; + const originalEnv = process.env; + + beforeEach(() => { + // Mock repositories + mockUserRepository = { + findOne: jest.fn() as jest.Mock, + save: jest.fn() as jest.Mock, + }; + + mockEmployeeRepository = { + save: jest.fn() as jest.Mock, + }; + + mockServiceRepository = { + save: jest.fn() as jest.Mock, + }; + + // Mock query builder for service associations + mockQueryBuilder = { + insert: jest.fn().mockReturnThis(), + into: jest.fn().mockReturnThis(), + values: jest.fn().mockReturnThis(), + execute: jest.fn().mockResolvedValue(undefined), + }; + + // Mock DataSource with proper typing + const mockGetRepository = (entity: EntityTarget) => { + if (entity === User) return mockUserRepository as Repository; + if (entity === Employee) return mockEmployeeRepository as Repository; + if (entity === Service) return mockServiceRepository as Repository; + throw new Error(`Repository not mocked for entity: ${entity}`); + }; + + mockDataSource = { + getRepository: jest.fn().mockImplementation(mockGetRepository), + createQueryBuilder: jest.fn().mockReturnValue(mockQueryBuilder), + }; + + // Mock bcrypt + (bcrypt.hash as jest.Mock).mockResolvedValue('hashed-password'); + + // Setup test environment variables + process.env = { + ...originalEnv, + EMPLOYEE_EMAIL: 'employee@example.com', + EMPLOYEE_PASSWORD: 'employee123', + EMPLOYEE_PHONE: '+1234567890', + }; + + // Mock console methods + jest.spyOn(console, 'log').mockImplementation(() => {}); + jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + process.env = originalEnv; + jest.clearAllMocks(); + }); + + it('should create services, employee user, and employee when they do not exist', async () => { + // Mock that employee doesn't exist + (mockUserRepository.findOne as jest.Mock).mockResolvedValue(null); + + // Mock created services + const mockServices = [ + { id: '1', name: 'Haircut' }, + { id: '2', name: 'Hair Coloring' }, + { id: '3', name: 'Styling' }, + ]; + (mockServiceRepository.save as jest.Mock).mockResolvedValue(mockServices); + + // Mock created employee user + const mockEmployeeUser = { + id: 'user-1', + email: 'employee@example.com', + role: UserRole.EMPLOYEE, + }; + (mockUserRepository.save as jest.Mock).mockResolvedValue(mockEmployeeUser); + + // Mock created employee + const mockEmployee = { + id: 'employee-1', + user: mockEmployeeUser, + }; + (mockEmployeeRepository.save as jest.Mock).mockResolvedValue(mockEmployee); + + await createInitialData(mockDataSource as DataSource); + + // Verify services were created + expect(mockServiceRepository.save).toHaveBeenCalledWith([ + expect.objectContaining({ name: 'Haircut' }), + expect.objectContaining({ name: 'Hair Coloring' }), + expect.objectContaining({ name: 'Styling' }), + ]); + + // Verify employee user was created + expect(mockUserRepository.save).toHaveBeenCalledWith(expect.objectContaining({ + email: 'employee@example.com', + role: UserRole.EMPLOYEE, + })); + + // Verify employee was created + expect(mockEmployeeRepository.save).toHaveBeenCalledWith(expect.objectContaining({ + user: mockEmployeeUser, + specializations: ['haircut', 'coloring'], + })); + + // Verify services were associated with employee + expect(mockQueryBuilder.insert).toHaveBeenCalled(); + expect(mockQueryBuilder.into).toHaveBeenCalledWith('employee_services'); + expect(mockQueryBuilder.values).toHaveBeenCalledWith( + mockServices.map(service => ({ + employee_id: mockEmployee.id, + service_id: service.id, + })), + ); + + // Verify success message was logged + expect(console.log).toHaveBeenCalledWith('Initial data seeded successfully'); + }); + + it('should not create employee when it already exists', async () => { + // Mock that employee already exists + const existingEmployee = { + email: 'employee@example.com', + role: UserRole.EMPLOYEE, + }; + (mockUserRepository.findOne as jest.Mock).mockResolvedValue(existingEmployee); + + await createInitialData(mockDataSource as DataSource); + + // Verify services were still created + expect(mockServiceRepository.save).toHaveBeenCalled(); + + // Verify no employee creation attempts were made + expect(mockUserRepository.save).not.toHaveBeenCalled(); + expect(mockEmployeeRepository.save).not.toHaveBeenCalled(); + expect(mockQueryBuilder.insert).not.toHaveBeenCalled(); + + // Verify appropriate message was logged + expect(console.log).toHaveBeenCalledWith('Employee user already exists'); + }); + + it('should throw error when employee email is missing', async () => { + delete process.env.EMPLOYEE_EMAIL; + + await expect(createInitialData(mockDataSource as DataSource)).rejects.toThrow( + 'Employee email and password must be set in environment variables', + ); + + // Verify no database operations were attempted + expect(mockServiceRepository.save).not.toHaveBeenCalled(); + expect(mockUserRepository.findOne).not.toHaveBeenCalled(); + expect(mockUserRepository.save).not.toHaveBeenCalled(); + expect(mockEmployeeRepository.save).not.toHaveBeenCalled(); + }); + + it('should throw error when employee password is missing', async () => { + delete process.env.EMPLOYEE_PASSWORD; + + await expect(createInitialData(mockDataSource as DataSource)).rejects.toThrow( + 'Employee email and password must be set in environment variables', + ); + + // Verify no database operations were attempted + expect(mockServiceRepository.save).not.toHaveBeenCalled(); + expect(mockUserRepository.findOne).not.toHaveBeenCalled(); + expect(mockUserRepository.save).not.toHaveBeenCalled(); + expect(mockEmployeeRepository.save).not.toHaveBeenCalled(); + }); + + it('should handle database errors during service creation', async () => { + const dbError = new Error('Database error during service creation'); + (mockServiceRepository.save as jest.Mock).mockRejectedValue(dbError); + + await expect(createInitialData(mockDataSource as DataSource)).rejects.toThrow(dbError); + + // Verify error was logged + expect(console.error).toHaveBeenCalledWith('Error creating initial data:', dbError); + }); + + it('should handle database errors during employee creation', async () => { + // Mock that employee doesn't exist + (mockUserRepository.findOne as jest.Mock).mockResolvedValue(null); + // Mock services creation success + (mockServiceRepository.save as jest.Mock).mockResolvedValue([]); + // Mock employee creation error + const dbError = new Error('Database error during employee creation'); + (mockUserRepository.save as jest.Mock).mockRejectedValue(dbError); + + await expect(createInitialData(mockDataSource as DataSource)).rejects.toThrow(dbError); + + // Verify error was logged + expect(console.error).toHaveBeenCalledWith('Error creating initial data:', dbError); + }); + + it('should handle password hashing errors', async () => { + // Mock that employee doesn't exist + (mockUserRepository.findOne as jest.Mock).mockResolvedValue(null); + // Mock services creation success + (mockServiceRepository.save as jest.Mock).mockResolvedValue([]); + // Mock bcrypt error + const hashError = new Error('Hashing failed'); + (bcrypt.hash as jest.Mock).mockRejectedValue(hashError); + + await expect(createInitialData(mockDataSource as DataSource)).rejects.toThrow(hashError); + + // Verify error was logged + expect(console.error).toHaveBeenCalledWith('Error creating initial data:', hashError); + }); +}); diff --git a/backend/src/database/seeds/create-sample-bookings.seed.spec.ts b/backend/src/database/seeds/create-sample-bookings.seed.spec.ts new file mode 100644 index 00000000..2c581b75 --- /dev/null +++ b/backend/src/database/seeds/create-sample-bookings.seed.spec.ts @@ -0,0 +1,223 @@ +import { DataSource, Repository } from 'typeorm'; +import { User, UserRole } from '../../users/entities/user.entity'; +import { Employee } from '../../employees/entities/employee.entity'; +import { Service } from '../../services/entities/service.entity'; +import { Booking, BookingStatus } from '../../bookings/entities/booking.entity'; +import { createSampleBookings } from './create-sample-bookings.seed'; +import * as bcrypt from 'bcrypt'; +import { faker } from '@faker-js/faker'; + +jest.mock('bcrypt'); +jest.mock('@faker-js/faker', () => ({ + faker: { + person: { + firstName: jest.fn().mockReturnValue('John'), + lastName: jest.fn().mockReturnValue('Doe'), + }, + internet: { + email: jest.fn().mockReturnValue('john.doe@example.com'), + }, + string: { + numeric: jest.fn().mockReturnValue('12345678'), + }, + helpers: { + arrayElement: jest.fn(), + maybe: jest.fn(), + }, + date: { + between: jest.fn(), + }, + lorem: { + sentence: jest.fn().mockReturnValue('Sample note'), + }, + }, +})); + +describe('createSampleBookings', () => { + let mockDataSource: Partial; + let mockUserRepository: Partial>; + let mockEmployeeRepository: Partial>; + let mockServiceRepository: Partial>; + let mockBookingRepository: Partial>; + const originalEnv = process.env; + + beforeEach(() => { + // Mock repositories + mockUserRepository = { + save: jest.fn() as jest.Mock, + }; + + mockEmployeeRepository = { + findOne: jest.fn() as jest.Mock, + }; + + mockServiceRepository = { + find: jest.fn() as jest.Mock, + }; + + mockBookingRepository = { + save: jest.fn() as jest.Mock, + }; + + // Mock DataSource with proper typing + const mockGetRepository = (entity: any) => { + if (entity === User) return mockUserRepository as Repository; + if (entity === Employee) return mockEmployeeRepository as Repository; + if (entity === Service) return mockServiceRepository as Repository; + if (entity === Booking) return mockBookingRepository as Repository; + throw new Error(`Repository not mocked for entity: ${entity}`); + }; + + mockDataSource = { + getRepository: jest.fn().mockImplementation(mockGetRepository), + }; + + // Mock bcrypt + (bcrypt.hash as jest.Mock).mockResolvedValue('hashed-password'); + + // Setup test environment variables + process.env = { + ...originalEnv, + EMPLOYEE_EMAIL: 'employee@example.com', + }; + + // Mock console methods + jest.spyOn(console, 'log').mockImplementation(() => {}); + jest.spyOn(console, 'error').mockImplementation(() => {}); + + // Reset faker mocks + jest.clearAllMocks(); + }); + + afterEach(() => { + process.env = originalEnv; + jest.clearAllMocks(); + }); + + it('should create sample customers and bookings when employee and services exist', async () => { + // Mock employee + const mockEmployee = { + id: 'employee-1', + user: { email: 'employee@example.com' }, + }; + (mockEmployeeRepository.findOne as jest.Mock).mockResolvedValue(mockEmployee); + + // Mock services + const mockServices = [ + { id: 'service-1', name: 'Haircut', duration: 30, price: 30 }, + { id: 'service-2', name: 'Styling', duration: 45, price: 40 }, + ]; + (mockServiceRepository.find as jest.Mock).mockResolvedValue(mockServices); + + // Mock faker array element to return first items + (faker.helpers.arrayElement as jest.Mock) + .mockReturnValueOnce(mockServices[0]) // First service + .mockReturnValue({ id: 'customer-1' }); // Customer for subsequent calls + + // Mock date generation + const mockDate = new Date('2024-01-01T10:00:00Z'); + (faker.date.between as jest.Mock).mockReturnValue(mockDate); + + // Mock maybe function to always return a note + (faker.helpers.maybe as jest.Mock).mockImplementation((callback) => callback()); + + await createSampleBookings(mockDataSource as DataSource); + + // Verify customers were created + expect(mockUserRepository.save).toHaveBeenCalledTimes(10); + expect(mockUserRepository.save).toHaveBeenCalledWith(expect.objectContaining({ + firstName: 'John', + lastName: 'Doe', + email: 'john.doe@example.com', + role: UserRole.CUSTOMER, + })); + + // Verify bookings were created + expect(mockBookingRepository.save).toHaveBeenCalled(); + const savedBookings = (mockBookingRepository.save as jest.Mock).mock.calls[0][0]; + expect(savedBookings).toHaveLength(20); + expect(savedBookings[0]).toEqual(expect.objectContaining({ + employee: mockEmployee, + service: mockServices[0], + startTime: mockDate, + endTime: expect.any(Date), + notes: 'Sample note', + })); + + // Verify success message was logged + expect(console.log).toHaveBeenCalledWith('Sample bookings created successfully'); + }); + + it('should throw error when employee is not found', async () => { + (mockEmployeeRepository.findOne as jest.Mock).mockResolvedValue(null); + + await expect(createSampleBookings(mockDataSource as DataSource)).rejects.toThrow( + 'Employee not found. Please run initial data seed first.', + ); + + // Verify no bookings were created + expect(mockBookingRepository.save).not.toHaveBeenCalled(); + }); + + it('should throw error when no services exist', async () => { + // Mock employee exists but no services + const mockEmployee = { + id: 'employee-1', + user: { email: 'employee@example.com' }, + }; + (mockEmployeeRepository.findOne as jest.Mock).mockResolvedValue(mockEmployee); + (mockServiceRepository.find as jest.Mock).mockResolvedValue([]); + + await expect(createSampleBookings(mockDataSource as DataSource)).rejects.toThrow( + 'No services found. Please run initial data seed first.', + ); + + // Verify no bookings were created + expect(mockBookingRepository.save).not.toHaveBeenCalled(); + }); + + it('should create bookings with cancelled status and cancellation details', async () => { + // Mock employee and services + const mockEmployee = { + id: 'employee-1', + user: { email: 'employee@example.com' }, + }; + const mockServices = [{ id: 'service-1', duration: 30, price: 30 }]; + (mockEmployeeRepository.findOne as jest.Mock).mockResolvedValue(mockEmployee); + (mockServiceRepository.find as jest.Mock).mockResolvedValue(mockServices); + + // Mock faker to create a cancelled booking + (faker.helpers.arrayElement as jest.Mock) + .mockReturnValueOnce(mockServices[0]) // Service + .mockReturnValueOnce({ id: 'customer-1' }) // Customer + .mockReturnValue(BookingStatus.CANCELLED); // Status + + const mockStartDate = new Date('2024-01-01T10:00:00Z'); + const mockCancelDate = new Date('2024-01-01T09:00:00Z'); + (faker.date.between as jest.Mock) + .mockReturnValueOnce(mockStartDate) // Booking start time + .mockReturnValue(mockCancelDate); // Cancellation time + + await createSampleBookings(mockDataSource as DataSource); + + // Verify cancelled booking was created with correct details + const savedBookings = (mockBookingRepository.save as jest.Mock).mock.calls[0][0]; + const cancelledBooking = savedBookings.find((b: any) => b.status === BookingStatus.CANCELLED); + expect(cancelledBooking).toBeDefined(); + expect(cancelledBooking).toEqual(expect.objectContaining({ + status: BookingStatus.CANCELLED, + cancelledAt: mockCancelDate, + cancellationReason: expect.any(String), + })); + }); + + it('should handle database errors', async () => { + const dbError = new Error('Database error'); + (mockEmployeeRepository.findOne as jest.Mock).mockRejectedValue(dbError); + + await expect(createSampleBookings(mockDataSource as DataSource)).rejects.toThrow(dbError); + + // Verify error was logged + expect(console.error).toHaveBeenCalledWith('Error creating sample bookings:', dbError); + }); +}); diff --git a/backend/src/database/seeds/remove-admin-user.spec.ts b/backend/src/database/seeds/remove-admin-user.spec.ts new file mode 100644 index 00000000..350e1814 --- /dev/null +++ b/backend/src/database/seeds/remove-admin-user.spec.ts @@ -0,0 +1,119 @@ +import { DataSource, Repository, DataSourceOptions } from 'typeorm'; +import { User } from '../../users/entities/user.entity'; +import { removeAdminUser, createDataSource } from './remove-admin-user'; + +describe('removeAdminUser', () => { + let mockDataSource: Partial; + let mockUserRepository: Partial>; + const originalEnv = process.env; + + beforeEach(() => { + // Mock repository + mockUserRepository = { + delete: jest.fn() as jest.Mock, + }; + + // Mock DataSource + mockDataSource = { + getRepository: jest.fn().mockReturnValue(mockUserRepository), + }; + + // Setup test environment variables + process.env = { + ...originalEnv, + ADMIN_EMAIL: 'admin@example.com', + }; + + // Mock console methods + jest.spyOn(console, 'log').mockImplementation(() => {}); + jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + process.env = originalEnv; + jest.clearAllMocks(); + }); + + it('should successfully remove admin user', async () => { + (mockUserRepository.delete as jest.Mock).mockResolvedValue({ affected: 1 }); + + const result = await removeAdminUser(mockDataSource as DataSource); + + expect(result).toBe(true); + expect(mockUserRepository.delete).toHaveBeenCalledWith({ + email: 'admin@example.com', + }); + expect(console.log).toHaveBeenCalledWith('Admin user removed successfully'); + }); + + it('should handle case when admin user does not exist', async () => { + (mockUserRepository.delete as jest.Mock).mockResolvedValue({ affected: 0 }); + + const result = await removeAdminUser(mockDataSource as DataSource); + + expect(result).toBe(false); + expect(mockUserRepository.delete).toHaveBeenCalledWith({ + email: 'admin@example.com', + }); + expect(console.log).toHaveBeenCalledWith('Admin user not found'); + }); + + it('should throw error when admin email is not set', async () => { + delete process.env.ADMIN_EMAIL; + + await expect(removeAdminUser(mockDataSource as DataSource)).rejects.toThrow( + 'Admin email must be set in environment variables', + ); + + expect(mockUserRepository.delete).not.toHaveBeenCalled(); + }); + + it('should handle database errors', async () => { + const dbError = new Error('Database error'); + (mockUserRepository.delete as jest.Mock).mockRejectedValue(dbError); + + await expect(removeAdminUser(mockDataSource as DataSource)).rejects.toThrow(dbError); + + expect(console.error).toHaveBeenCalledWith('Error removing admin user:', dbError); + }); +}); + +describe('createDataSource', () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { + ...originalEnv, + DATABASE_URL: 'postgres://user:pass@localhost:5432/db', + }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it('should create DataSource with correct configuration', () => { + const dataSource = createDataSource(); + + expect(dataSource).toBeInstanceOf(DataSource); + expect(dataSource.options).toEqual(expect.objectContaining({ + type: 'postgres', + entities: [User], + synchronize: false, + ssl: { + rejectUnauthorized: false, + }, + })); + // Check the URL separately as it's a runtime value + expect((dataSource.options as any).url).toBe('postgres://user:pass@localhost:5432/db'); + }); + + it('should use environment variables for database configuration', () => { + const testUrl = 'postgres://test:test@test:5432/testdb'; + process.env.DATABASE_URL = testUrl; + + const dataSource = createDataSource(); + + expect((dataSource.options as any).url).toBe(testUrl); + }); +}); diff --git a/backend/src/database/seeds/remove-admin-user.ts b/backend/src/database/seeds/remove-admin-user.ts index 338e36cd..4935525d 100644 --- a/backend/src/database/seeds/remove-admin-user.ts +++ b/backend/src/database/seeds/remove-admin-user.ts @@ -5,7 +5,7 @@ import { User } from "../../users/entities/user.entity"; // Load environment variables config(); -const dataSource = new DataSource({ +export const createDataSource = () => new DataSource({ type: "postgres", url: process.env.DATABASE_URL, entities: [User], @@ -15,12 +15,8 @@ const dataSource = new DataSource({ }, }); -async function removeAdminUser() { +export async function removeAdminUser(dataSource: DataSource) { try { - console.log("Connecting to database..."); - await dataSource.initialize(); - console.log("Connected to database"); - const userRepository = dataSource.getRepository(User); const adminEmail = process.env.ADMIN_EMAIL; @@ -32,15 +28,34 @@ async function removeAdminUser() { if (result.affected > 0) { console.log("Admin user removed successfully"); + return true; } else { console.log("Admin user not found"); + return false; } - - process.exit(0); } catch (error) { console.error("Error removing admin user:", error); - process.exit(1); + throw error; } } -removeAdminUser(); +// Only run if this file is being executed directly +if (require.main === module) { + const dataSource = createDataSource(); + + async function main() { + try { + console.log("Connecting to database..."); + await dataSource.initialize(); + console.log("Connected to database"); + + await removeAdminUser(dataSource); + process.exit(0); + } catch (error) { + console.error("Error in main:", error); + process.exit(1); + } + } + + main(); +} diff --git a/backend/src/database/seeds/run-seeds.spec.ts b/backend/src/database/seeds/run-seeds.spec.ts new file mode 100644 index 00000000..3db25280 --- /dev/null +++ b/backend/src/database/seeds/run-seeds.spec.ts @@ -0,0 +1,129 @@ +import { DataSource } from 'typeorm'; +import { createDataSource, runSeeds } from './run-seeds'; +import { createAdminUser } from './create-admin-user.seed'; +import { createInitialData } from './create-initial-data.seed'; +import { createSampleBookings } from './create-sample-bookings.seed'; + +jest.mock('./create-admin-user.seed'); +jest.mock('./create-initial-data.seed'); +jest.mock('./create-sample-bookings.seed'); + +describe('run-seeds', () => { + let mockDataSource: Partial; + const originalEnv = process.env; + + beforeEach(() => { + // Mock DataSource + mockDataSource = { + initialize: jest.fn().mockResolvedValue(undefined), + }; + + // Mock seed functions + (createAdminUser as jest.Mock).mockResolvedValue(undefined); + (createInitialData as jest.Mock).mockResolvedValue(undefined); + (createSampleBookings as jest.Mock).mockResolvedValue(undefined); + + // Setup test environment variables + process.env = { + ...originalEnv, + DATABASE_URL: 'postgres://user:pass@localhost:5432/db', + }; + + // Mock console methods + jest.spyOn(console, 'log').mockImplementation(() => {}); + jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + process.env = originalEnv; + jest.clearAllMocks(); + }); + + describe('createDataSource', () => { + it('should create DataSource with correct configuration', () => { + const dataSource = createDataSource(); + + expect(dataSource).toBeInstanceOf(DataSource); + expect(dataSource.options).toEqual(expect.objectContaining({ + type: 'postgres', + entities: ['src/**/*.entity{.ts,.js}'], + synchronize: false, + ssl: { + rejectUnauthorized: false, + }, + })); + expect((dataSource.options as any).url).toBe('postgres://user:pass@localhost:5432/db'); + }); + + it('should use environment variables for database configuration', () => { + const testUrl = 'postgres://test:test@test:5432/testdb'; + process.env.DATABASE_URL = testUrl; + + const dataSource = createDataSource(); + + expect((dataSource.options as any).url).toBe(testUrl); + }); + }); + + describe('runSeeds', () => { + it('should run all seeds in sequence', async () => { + const result = await runSeeds(mockDataSource as DataSource); + + expect(mockDataSource.initialize).toHaveBeenCalled(); + expect(createAdminUser).toHaveBeenCalledWith(mockDataSource); + expect(createInitialData).toHaveBeenCalledWith(mockDataSource); + expect(createSampleBookings).toHaveBeenCalledWith(mockDataSource); + expect(console.log).toHaveBeenCalledWith('Connected to database'); + expect(console.log).toHaveBeenCalledWith('All seeds completed successfully'); + expect(result).toBe(true); + }); + + it('should handle database initialization errors', async () => { + const dbError = new Error('Database initialization failed'); + mockDataSource.initialize = jest.fn().mockRejectedValue(dbError); + + await expect(runSeeds(mockDataSource as DataSource)).rejects.toThrow(dbError); + + expect(console.error).toHaveBeenCalledWith('Error running seeds:', dbError); + expect(createAdminUser).not.toHaveBeenCalled(); + expect(createInitialData).not.toHaveBeenCalled(); + expect(createSampleBookings).not.toHaveBeenCalled(); + }); + + it('should handle admin user creation errors', async () => { + const seedError = new Error('Admin user creation failed'); + (createAdminUser as jest.Mock).mockRejectedValue(seedError); + + await expect(runSeeds(mockDataSource as DataSource)).rejects.toThrow(seedError); + + expect(console.error).toHaveBeenCalledWith('Error running seeds:', seedError); + expect(createAdminUser).toHaveBeenCalled(); + expect(createInitialData).not.toHaveBeenCalled(); + expect(createSampleBookings).not.toHaveBeenCalled(); + }); + + it('should handle initial data creation errors', async () => { + const seedError = new Error('Initial data creation failed'); + (createInitialData as jest.Mock).mockRejectedValue(seedError); + + await expect(runSeeds(mockDataSource as DataSource)).rejects.toThrow(seedError); + + expect(console.error).toHaveBeenCalledWith('Error running seeds:', seedError); + expect(createAdminUser).toHaveBeenCalled(); + expect(createInitialData).toHaveBeenCalled(); + expect(createSampleBookings).not.toHaveBeenCalled(); + }); + + it('should handle sample bookings creation errors', async () => { + const seedError = new Error('Sample bookings creation failed'); + (createSampleBookings as jest.Mock).mockRejectedValue(seedError); + + await expect(runSeeds(mockDataSource as DataSource)).rejects.toThrow(seedError); + + expect(console.error).toHaveBeenCalledWith('Error running seeds:', seedError); + expect(createAdminUser).toHaveBeenCalled(); + expect(createInitialData).toHaveBeenCalled(); + expect(createSampleBookings).toHaveBeenCalled(); + }); + }); +}); diff --git a/backend/src/database/seeds/run-seeds.ts b/backend/src/database/seeds/run-seeds.ts index 0cf7ddd1..2391fb49 100644 --- a/backend/src/database/seeds/run-seeds.ts +++ b/backend/src/database/seeds/run-seeds.ts @@ -7,7 +7,7 @@ import { createSampleBookings } from "./create-sample-bookings.seed"; // Load environment variables config(); -const dataSource = new DataSource({ +export const createDataSource = () => new DataSource({ type: "postgres", url: process.env.DATABASE_URL, entities: ["src/**/*.entity{.ts,.js}"], @@ -17,7 +17,7 @@ const dataSource = new DataSource({ } }); -const runSeeds = async () => { +export const runSeeds = async (dataSource: DataSource) => { try { await dataSource.initialize(); console.log("Connected to database"); @@ -28,11 +28,18 @@ const runSeeds = async () => { await createSampleBookings(dataSource); console.log("All seeds completed successfully"); - process.exit(0); + return true; } catch (error) { console.error("Error running seeds:", error); - process.exit(1); + throw error; } }; -runSeeds(); +// Only run if this file is being executed directly +if (require.main === module) { + const dataSource = createDataSource(); + + runSeeds(dataSource) + .then(() => process.exit(0)) + .catch(() => process.exit(1)); +} diff --git a/backend/src/database/seeds/update-admin-password-runner.ts b/backend/src/database/seeds/update-admin-password-runner.ts deleted file mode 100644 index 143c19a1..00000000 --- a/backend/src/database/seeds/update-admin-password-runner.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { config } from "dotenv"; -import { DataSource } from "typeorm"; -import { updateAdminPassword } from "./update-admin-password.seed"; -import * as fs from 'fs'; -import * as dotenv from 'dotenv'; -import * as path from 'path'; - -// Read .env file directly and parse it properly -const envPath = path.resolve(process.cwd(), '.env'); -const envConfig = dotenv.parse(fs.readFileSync(envPath)); - -const dataSource = new DataSource({ - type: "postgres", - url: envConfig.DATABASE_URL, - entities: ["src/**/*.entity{.ts,.js}"], - synchronize: false, - ssl: { - rejectUnauthorized: false - } -}); - -const runSeed = async () => { - try { - await dataSource.initialize(); - console.log("Connected to database"); - - await updateAdminPassword(dataSource); - - console.log("Password update completed successfully"); - process.exit(0); - } catch (error) { - console.error("Error running seed:", error); - process.exit(1); - } -}; - -runSeed(); diff --git a/backend/src/database/seeds/update-admin-password.seed.spec.ts b/backend/src/database/seeds/update-admin-password.seed.spec.ts new file mode 100644 index 00000000..1756fd1a --- /dev/null +++ b/backend/src/database/seeds/update-admin-password.seed.spec.ts @@ -0,0 +1,173 @@ +import { DataSource, Repository } from 'typeorm'; +import { updateAdminPassword } from './update-admin-password.seed'; +import * as fs from 'fs'; +import * as dotenv from 'dotenv'; +import * as path from 'path'; + +// Mock User entity +const mockUserEntity = { + id: 'user-1', + email: 'admin@example.com', + password: 'old-password', + validatePassword: jest.fn().mockResolvedValue(true), +}; + +jest.mock('../../users/entities/user.entity', () => ({ + User: jest.fn(), +})); +jest.mock('fs'); +jest.mock('dotenv'); +jest.mock('path'); + +describe('updateAdminPassword', () => { + let mockDataSource: Partial; + let mockUserRepository: Partial>; + + beforeEach(() => { + // Mock repository + mockUserRepository = { + findOne: jest.fn() as jest.Mock, + save: jest.fn() as jest.Mock, + }; + + // Mock DataSource + mockDataSource = { + getRepository: jest.fn().mockReturnValue(mockUserRepository), + }; + + // Mock path.resolve + (path.resolve as jest.Mock).mockReturnValue('/fake/path/.env'); + + // Mock fs.readFileSync + (fs.readFileSync as jest.Mock).mockReturnValue('mock env file content'); + + // Mock dotenv.parse + (dotenv.parse as jest.Mock).mockReturnValue({ + ADMIN_EMAIL: 'admin@example.com', + ADMIN_PASSWORD: 'new-password', + }); + + // Mock console methods + jest.spyOn(console, 'log').mockImplementation(() => {}); + jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should successfully update admin password', async () => { + // Mock finding the admin user + (mockUserRepository.findOne as jest.Mock) + .mockResolvedValueOnce({ ...mockUserEntity }) // First call - finding user + .mockResolvedValueOnce({ ...mockUserEntity, password: 'new-password' }); // Second call - verification + + await updateAdminPassword(mockDataSource as DataSource); + + // Verify env file was read + expect(fs.readFileSync).toHaveBeenCalledWith('/fake/path/.env'); + expect(dotenv.parse).toHaveBeenCalledWith('mock env file content'); + + // Verify user was found and updated + expect(mockUserRepository.findOne).toHaveBeenCalledWith({ + where: { email: 'admin@example.com' }, + }); + expect(mockUserRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ + email: 'admin@example.com', + password: 'new-password', + }), + ); + + // Verify password was validated + expect(mockUserEntity.validatePassword).toHaveBeenCalledWith('new-password'); + }); + + it('should throw error when admin user is not found', async () => { + (mockUserRepository.findOne as jest.Mock).mockResolvedValue(null); + + await expect(updateAdminPassword(mockDataSource as DataSource)).rejects.toThrow( + 'Admin user not found', + ); + + expect(mockUserRepository.save).not.toHaveBeenCalled(); + }); + + it('should throw error when env file cannot be read', async () => { + const fsError = new Error('Cannot read env file'); + (fs.readFileSync as jest.Mock).mockImplementation(() => { + throw fsError; + }); + + await expect(updateAdminPassword(mockDataSource as DataSource)).rejects.toThrow(fsError); + + expect(mockUserRepository.findOne).not.toHaveBeenCalled(); + expect(mockUserRepository.save).not.toHaveBeenCalled(); + }); + + it('should throw error when admin email is missing from env', async () => { + (dotenv.parse as jest.Mock).mockReturnValue({ + ADMIN_PASSWORD: 'new-password', + }); + + await expect(updateAdminPassword(mockDataSource as DataSource)).rejects.toThrow( + 'Admin email and password must be set in environment variables', + ); + + expect(mockUserRepository.findOne).not.toHaveBeenCalled(); + expect(mockUserRepository.save).not.toHaveBeenCalled(); + }); + + it('should throw error when admin password is missing from env', async () => { + (dotenv.parse as jest.Mock).mockReturnValue({ + ADMIN_EMAIL: 'admin@example.com', + }); + + await expect(updateAdminPassword(mockDataSource as DataSource)).rejects.toThrow( + 'Admin email and password must be set in environment variables', + ); + + expect(mockUserRepository.findOne).not.toHaveBeenCalled(); + expect(mockUserRepository.save).not.toHaveBeenCalled(); + }); + + it('should throw error when password update cannot be verified', async () => { + // Mock finding the admin user for update + (mockUserRepository.findOne as jest.Mock) + .mockResolvedValueOnce({ ...mockUserEntity }) // First call - finding user + .mockResolvedValueOnce(null); // Second call - verification fails + + await expect(updateAdminPassword(mockDataSource as DataSource)).rejects.toThrow( + 'Could not verify password update', + ); + }); + + it('should handle database errors during save', async () => { + // Mock finding the admin user + (mockUserRepository.findOne as jest.Mock).mockResolvedValue({ ...mockUserEntity }); + + // Mock save error + const dbError = new Error('Database error during save'); + (mockUserRepository.save as jest.Mock).mockRejectedValue(dbError); + + await expect(updateAdminPassword(mockDataSource as DataSource)).rejects.toThrow(dbError); + + expect(console.error).toHaveBeenCalledWith('Error updating admin password:', dbError); + }); + + it('should log password analysis information', async () => { + // Mock finding the admin user + (mockUserRepository.findOne as jest.Mock) + .mockResolvedValueOnce({ ...mockUserEntity }) + .mockResolvedValueOnce({ ...mockUserEntity, password: 'new-password' }); + + await updateAdminPassword(mockDataSource as DataSource); + + // Verify password analysis was logged + expect(console.log).toHaveBeenCalledWith(expect.stringContaining('Password analysis before update')); + expect(console.log).toHaveBeenCalledWith(expect.stringContaining('Raw password'), 'new-password'); + expect(console.log).toHaveBeenCalledWith(expect.stringContaining('Length'), expect.any(Number)); + expect(console.log).toHaveBeenCalledWith('Admin password updated in database'); + expect(console.log).toHaveBeenCalledWith('Password verification after update:', true); + }); +}); diff --git a/backend/src/database/seeds/verify-admin-password.ts b/backend/src/database/seeds/verify-admin-password.ts deleted file mode 100644 index a5258be6..00000000 --- a/backend/src/database/seeds/verify-admin-password.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { DataSource } from "typeorm"; -import { User } from "../../users/entities/user.entity"; -import * as bcrypt from "bcrypt"; -import * as fs from 'fs'; -import * as dotenv from 'dotenv'; -import * as path from 'path'; - -const verifyPassword = async () => { - try { - // Read .env file directly and parse it properly - const envPath = path.resolve(process.cwd(), '.env'); - const envConfig = dotenv.parse(fs.readFileSync(envPath)); - - const dataSource = new DataSource({ - type: "postgres", - url: envConfig.DATABASE_URL, - entities: ["src/**/*.entity{.ts,.js}"], - synchronize: false, - ssl: { - rejectUnauthorized: false - } - }); - - await dataSource.initialize(); - console.log("Connected to database"); - - const adminEmail = envConfig.ADMIN_EMAIL; - const adminPassword = envConfig.ADMIN_PASSWORD; - - if (!adminEmail || !adminPassword) { - throw new Error("Admin email and password must be set in environment variables"); - } - - console.log("\nPassword analysis:"); - console.log("Raw password:", adminPassword); - console.log("Length:", adminPassword.length); - console.log("Character codes:"); - for (let i = 0; i < adminPassword.length; i++) { - console.log(`${i}: '${adminPassword[i]}' (${adminPassword.charCodeAt(i)})`); - } - - const userRepository = dataSource.getRepository(User); - - // Find admin user - const adminUser = await userRepository.findOne({ - where: { email: adminEmail }, - select: ["id", "email", "password"] // Explicitly select password field - }); - - if (!adminUser) { - console.log("Admin user not found"); - process.exit(1); - } - - console.log("\nAdmin user found"); - console.log("Email:", adminUser.email); - console.log("Stored hash:", adminUser.password); - - // Test password verification - const isPasswordValid = await bcrypt.compare(adminPassword, adminUser.password); - console.log("Password verification result:", isPasswordValid); - - // Create a new hash for comparison - const newHash = await bcrypt.hash(adminPassword, 10); - console.log("\nNew hash created with same password:", newHash); - const verifyNewHash = await bcrypt.compare(adminPassword, newHash); - console.log("Verification with new hash:", verifyNewHash); - - process.exit(0); - } catch (error) { - console.error("Error:", error); - process.exit(1); - } -}; - -verifyPassword(); diff --git a/backend/src/users/users.service.spec.ts b/backend/src/users/users.service.spec.ts new file mode 100644 index 00000000..ce546820 --- /dev/null +++ b/backend/src/users/users.service.spec.ts @@ -0,0 +1,134 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { UsersService } from './users.service'; +import { User } from './entities/user.entity'; +import { NotFoundException } from '@nestjs/common'; + +describe('UsersService', () => { + let service: UsersService; + let userRepository: Repository; + + const mockUser = { + id: 'user-1', + email: 'test@example.com', + firstName: 'John', + lastName: 'Doe', + password: 'hashedPassword', + }; + + const mockUserRepository = { + findOne: jest.fn(), + create: jest.fn(), + save: jest.fn(), + update: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + UsersService, + { + provide: getRepositoryToken(User), + useValue: mockUserRepository, + }, + ], + }).compile(); + + service = module.get(UsersService); + userRepository = module.get>(getRepositoryToken(User)); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('findOne', () => { + it('should return a user when found', async () => { + mockUserRepository.findOne.mockResolvedValue(mockUser); + + const result = await service.findOne('user-1'); + expect(result).toEqual(mockUser); + expect(userRepository.findOne).toHaveBeenCalledWith({ + where: { id: 'user-1' }, + }); + }); + + it('should throw NotFoundException when user not found', async () => { + mockUserRepository.findOne.mockResolvedValue(null); + + await expect(service.findOne('non-existent')).rejects.toThrow( + NotFoundException, + ); + }); + }); + + describe('findByEmail', () => { + it('should return a user when found', async () => { + mockUserRepository.findOne.mockResolvedValue(mockUser); + + const result = await service.findByEmail('test@example.com'); + expect(result).toEqual(mockUser); + expect(userRepository.findOne).toHaveBeenCalledWith({ + where: { email: 'test@example.com' }, + }); + }); + + it('should return null when user not found', async () => { + mockUserRepository.findOne.mockResolvedValue(null); + + const result = await service.findByEmail('nonexistent@example.com'); + expect(result).toBeNull(); + }); + }); + + describe('create', () => { + const createUserData = { + email: 'new@example.com', + firstName: 'Jane', + lastName: 'Doe', + password: 'password123', + }; + + it('should create and return a new user', async () => { + mockUserRepository.create.mockReturnValue(createUserData); + mockUserRepository.save.mockResolvedValue({ id: 'new-user', ...createUserData }); + + const result = await service.create(createUserData); + + expect(result).toEqual({ id: 'new-user', ...createUserData }); + expect(userRepository.create).toHaveBeenCalledWith(createUserData); + expect(userRepository.save).toHaveBeenCalledWith(createUserData); + }); + }); + + describe('update', () => { + const updateData = { + firstName: 'Updated', + lastName: 'Name', + }; + + it('should update and return the user', async () => { + const updatedUser = { ...mockUser, ...updateData }; + mockUserRepository.update.mockResolvedValue({ affected: 1 }); + mockUserRepository.findOne.mockResolvedValue(updatedUser); + + const result = await service.update('user-1', updateData); + + expect(result).toEqual(updatedUser); + expect(userRepository.update).toHaveBeenCalledWith('user-1', updateData); + expect(userRepository.findOne).toHaveBeenCalledWith({ + where: { id: 'user-1' }, + }); + }); + + it('should throw NotFoundException when user not found during update', async () => { + mockUserRepository.update.mockResolvedValue({ affected: 1 }); + mockUserRepository.findOne.mockResolvedValue(null); + + await expect(service.update('non-existent', updateData)).rejects.toThrow( + NotFoundException, + ); + }); + }); +}); diff --git a/codecov.yml b/codecov.yml index be8fd9ac..3d9691b2 100644 --- a/codecov.yml +++ b/codecov.yml @@ -1,35 +1,32 @@ coverage: + precision: 2 + round: down + range: "70...100" status: project: - backend: - paths: - - "backend/" + default: target: 80% threshold: 1% - patch: - backend: paths: - - "backend/" + - "backend/src" + patch: + default: target: 80% + paths: + - "backend/src" ignore: - - "frontend/" - - "**/node_modules/" - - "**/*.spec.ts" - - "**/*.test.ts" - - "**/test/" - - "**/tests/" - - "coverage/" - - "backend/dist/" - -flags: - backend: - paths: - - backend/ + - "frontend/**/*" + - "backend/node_modules/**/*" + - "backend/dist/**/*" + - "backend/test/**/*" + - "backend/coverage/**/*" + - "backend/**/*.spec.ts" + - "backend/**/*.test.ts" comment: layout: "reach, diff, flags, files" behavior: default require_changes: false - require_base: no - require_head: yes + require_base: false + require_head: true