diff --git a/apps/backend/src/courses/__tests__/courses.service.repository.spec.ts b/apps/backend/src/courses/__tests__/courses.service.repository.spec.ts new file mode 100644 index 00000000..1544febf --- /dev/null +++ b/apps/backend/src/courses/__tests__/courses.service.repository.spec.ts @@ -0,0 +1,129 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { NotFoundException } from '@nestjs/common'; +import { CACHE_MANAGER } from '@nestjs/cache-manager'; +import { CoursesService } from '../courses.service'; +import { CoursesRepository } from '../../repositories/courses-repository.interface'; +import { COURSES_REPOSITORY_TOKEN } from '../../repositories/repositories.module'; +import { Course } from '../course.entity'; +import { CourseQueryDto } from '../dto/course-query.dto'; + +describe('CoursesService', () => { + let service: CoursesService; + let mockRepository: jest.Mocked; + let mockCacheManager: jest.Mocked; + + beforeEach(async () => { + mockRepository = { + findById: jest.fn(), + findByIdWithDeleted: jest.fn(), + findAll: jest.fn(), + save: jest.fn(), + remove: jest.fn(), + }; + + mockCacheManager = { + del: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + CoursesService, + { + provide: COURSES_REPOSITORY_TOKEN, + useValue: mockRepository, + }, + { + provide: CACHE_MANAGER, + useValue: mockCacheManager, + }, + ], + }).compile(); + + service = module.get(CoursesService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('findAll', () => { + it('should return paginated courses', async () => { + const query: CourseQueryDto = { page: 1, limit: 20 }; + const result = { + data: [{ id: '1', title: 'Test Course' }] as Course[], + total: 1, + page: 1, + limit: 20, + }; + mockRepository.findAll.mockResolvedValue(result); + + const courses = await service.findAll(query); + expect(courses).toBe(result); + expect(mockRepository.findAll).toHaveBeenCalledWith(query); + }); + }); + + describe('findOne', () => { + it('should return a course when found', async () => { + const course = { id: '1', title: 'Test Course' } as Course; + mockRepository.findById.mockResolvedValue(course); + + const result = await service.findOne('1'); + expect(result).toBe(course); + expect(mockRepository.findById).toHaveBeenCalledWith('1'); + }); + + it('should throw NotFoundException when course not found', async () => { + mockRepository.findById.mockResolvedValue(null); + + await expect(service.findOne('1')).rejects.toThrow(NotFoundException); + }); + }); + + describe('create', () => { + it('should create a course and invalidate cache', async () => { + const courseData = { title: 'New Course', description: 'Test description' }; + const createdCourse = { id: '1', ...courseData } as Course; + + mockRepository.save.mockResolvedValue(createdCourse); + mockCacheManager.del.mockResolvedValue(undefined); + + const result = await service.create(courseData); + expect(result).toBe(createdCourse); + expect(mockRepository.save).toHaveBeenCalledWith(courseData); + expect(mockCacheManager.del).toHaveBeenCalledWith('courses:all'); + }); + }); + + describe('update', () => { + it('should update a course and invalidate cache', async () => { + const course = { id: '1', title: 'Old Title' } as Course; + const updateData = { title: 'New Title' }; + const updatedCourse = { ...course, ...updateData } as Course; + + mockRepository.findById.mockResolvedValue(course); + mockRepository.save.mockResolvedValue(updatedCourse); + mockCacheManager.del.mockResolvedValue(undefined); + + const result = await service.update('1', updateData); + expect(result).toBe(updatedCourse); + expect(mockRepository.save).toHaveBeenCalledWith({ ...course, ...updateData }); + expect(mockCacheManager.del).toHaveBeenCalledWith('courses:all'); + }); + }); + + describe('delete', () => { + it('should delete a course and invalidate cache', async () => { + const course = { id: '1', title: 'Test Course' } as Course; + + mockRepository.findById.mockResolvedValue(course); + mockRepository.remove.mockResolvedValue(course); + mockCacheManager.del.mockResolvedValue(undefined); + + const result = await service.delete('1'); + expect(result).toBe(course); + expect(mockRepository.remove).toHaveBeenCalledWith(course); + expect(mockCacheManager.del).toHaveBeenCalledWith('courses:all'); + }); + }); +}); \ No newline at end of file diff --git a/apps/backend/src/courses/courses.module.ts b/apps/backend/src/courses/courses.module.ts index 9334d27a..f9fe9bea 100644 --- a/apps/backend/src/courses/courses.module.ts +++ b/apps/backend/src/courses/courses.module.ts @@ -8,9 +8,10 @@ import { ModulesService } from './modules.service'; import { LessonsService } from './lessons.service'; import { CoursesController } from './courses.controller'; import { ModulesController } from './modules.controller'; +import { RepositoriesModule } from '../repositories/repositories.module'; @Module({ - imports: [TypeOrmModule.forFeature([Course, CourseModule, Lesson])], + imports: [TypeOrmModule.forFeature([Course, CourseModule, Lesson]), RepositoriesModule], providers: [CoursesService, ModulesService, LessonsService], controllers: [CoursesController, ModulesController], exports: [CoursesService], diff --git a/apps/backend/src/courses/courses.service.ts b/apps/backend/src/courses/courses.service.ts index 5ac66ebc..80dc1318 100644 --- a/apps/backend/src/courses/courses.service.ts +++ b/apps/backend/src/courses/courses.service.ts @@ -1,13 +1,12 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { Injectable, NotFoundException, Inject } from '@nestjs/common'; import { Cache } from 'cache-manager'; import { CACHE_MANAGER } from '@nestjs/cache-manager'; -import { Inject } from '@nestjs/common'; import { Course } from './course.entity'; import { CreateCourseDto } from './dto/create-course.dto'; import { UpdateCourseDto } from './dto/update-course.dto'; import { CourseQueryDto } from './dto/course-query.dto'; +import { CoursesRepository } from '../repositories/courses-repository.interface'; +import { COURSES_REPOSITORY_TOKEN } from '../repositories/repositories.module'; @Injectable() export class CoursesService { @@ -15,51 +14,28 @@ export class CoursesService { private readonly CACHE_TTL = 60; constructor( - @InjectRepository(Course) private repo: Repository, + @Inject(COURSES_REPOSITORY_TOKEN) private repo: CoursesRepository, @Inject(CACHE_MANAGER) private cacheManager: Cache, ) {} async findAll(query: CourseQueryDto = {}) { - const { search, level, page = 1, limit = 20 } = query; - - const qb = this.repo.createQueryBuilder('course') - .where('course.isPublished = :isPublished', { isPublished: true }) - .andWhere('course.isDeleted = :isDeleted', { isDeleted: false }); - - if (search) { - qb.andWhere( - '(course.title ILIKE :search OR course.description ILIKE :search)', - { search: `%${search}%` }, - ); - } - - if (level) { - qb.andWhere('course.level = :level', { level }); - } - - const offset = (page - 1) * limit; - qb.skip(offset).take(limit).orderBy('course.createdAt', 'DESC'); - - const [data, total] = await qb.getManyAndCount(); - - return { data, total, page, limit }; + return this.repo.findAll(query); } async findOne(id: string): Promise { - const course = await this.repo.findOne({ where: { id, isDeleted: false } }); + const course = await this.repo.findById(id); if (!course) throw new NotFoundException('Course not found'); return course; } async create(data: Partial) { - const course = await this.repo.save(this.repo.create(data)); + const course = await this.repo.save(data); await this.invalidateCache(); return course; } async update(id: string, data: Partial) { const course = await this.findOne(id); - if (!course) throw new NotFoundException('Course not found'); const updated = await this.repo.save({ ...course, ...data }); await this.invalidateCache(); return updated; @@ -67,7 +43,6 @@ export class CoursesService { async delete(id: string) { const course = await this.findOne(id); - if (!course) throw new NotFoundException('Course not found'); const removed = await this.repo.remove(course); await this.invalidateCache(); return removed; diff --git a/apps/backend/src/credentials/__tests__/credentials.service.repository.spec.ts b/apps/backend/src/credentials/__tests__/credentials.service.repository.spec.ts new file mode 100644 index 00000000..7ab7f28a --- /dev/null +++ b/apps/backend/src/credentials/__tests__/credentials.service.repository.spec.ts @@ -0,0 +1,145 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { CredentialsService } from '../credentials.service'; +import { CredentialsRepository } from '../../repositories/credentials-repository.interface'; +import { CREDENTIALS_REPOSITORY_TOKEN } from '../../repositories/repositories.module'; +import { StellarService } from '../../stellar/stellar.service'; +import { Credential } from '../credential.entity'; + +describe('CredentialsService', () => { + let service: CredentialsService; + let mockRepository: jest.Mocked; + let mockStellarService: jest.Mocked; + + beforeEach(async () => { + mockRepository = { + findById: jest.fn(), + findByUser: jest.fn(), + findByUserAndCourse: jest.fn(), + findByTxHash: jest.fn(), + save: jest.fn(), + remove: jest.fn(), + }; + + mockStellarService = { + issueCredential: jest.fn(), + verifyCredential: jest.fn(), + mintReward: jest.fn(), + } as any; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + CredentialsService, + { + provide: CREDENTIALS_REPOSITORY_TOKEN, + useValue: mockRepository, + }, + { + provide: StellarService, + useValue: mockStellarService, + }, + ], + }).compile(); + + service = module.get(CredentialsService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('issue', () => { + it('should return existing credential if already exists', async () => { + const existingCredential = { + id: '1', + userId: 'user1', + courseId: 'course1', + txHash: 'existing-tx', + } as Credential; + + mockRepository.findByUserAndCourse.mockResolvedValue(existingCredential); + + const result = await service.issue('user1', 'course1', 'stellar-key'); + expect(result).toBe(existingCredential); + expect(mockRepository.findByUserAndCourse).toHaveBeenCalledWith('user1', 'course1'); + expect(mockStellarService.issueCredential).not.toHaveBeenCalled(); + }); + + it('should issue new credential if none exists', async () => { + const newCredential = { + id: '1', + userId: 'user1', + courseId: 'course1', + txHash: 'new-tx-hash', + stellarPublicKey: 'stellar-key', + } as Credential; + + mockRepository.findByUserAndCourse.mockResolvedValue(null); + mockStellarService.issueCredential.mockResolvedValue('new-tx-hash'); + mockStellarService.mintReward.mockResolvedValue(undefined); + mockRepository.save.mockResolvedValue(newCredential); + + const result = await service.issue('user1', 'course1', 'stellar-key'); + + expect(result).toBe(newCredential); + expect(mockStellarService.issueCredential).toHaveBeenCalledWith('stellar-key', 'course1'); + expect(mockStellarService.mintReward).toHaveBeenCalledWith('stellar-key', 100); + expect(mockRepository.save).toHaveBeenCalledWith({ + userId: 'user1', + courseId: 'course1', + txHash: 'new-tx-hash', + stellarPublicKey: 'stellar-key', + }); + }); + + it('should handle mint reward failure gracefully', async () => { + const newCredential = { + id: '1', + userId: 'user1', + courseId: 'course1', + txHash: 'new-tx-hash', + stellarPublicKey: 'stellar-key', + } as Credential; + + mockRepository.findByUserAndCourse.mockResolvedValue(null); + mockStellarService.issueCredential.mockResolvedValue('new-tx-hash'); + mockStellarService.mintReward.mockRejectedValue(new Error('Mint failed')); + mockRepository.save.mockResolvedValue(newCredential); + + const result = await service.issue('user1', 'course1', 'stellar-key'); + + expect(result).toBe(newCredential); + expect(mockRepository.save).toHaveBeenCalled(); + }); + }); + + describe('findByUser', () => { + it('should return credentials for a user', async () => { + const credentials = [ + { id: '1', userId: 'user1', courseId: 'course1' }, + { id: '2', userId: 'user1', courseId: 'course2' }, + ] as Credential[]; + + mockRepository.findByUser.mockResolvedValue(credentials); + + const result = await service.findByUser('user1'); + expect(result).toBe(credentials); + expect(mockRepository.findByUser).toHaveBeenCalledWith('user1'); + }); + }); + + describe('verify', () => { + it('should verify a credential', async () => { + const credential = { id: '1', txHash: 'tx-hash' } as Credential; + const onChainData = { valid: true, timestamp: new Date() }; + + mockRepository.findByTxHash.mockResolvedValue(credential); + mockStellarService.verifyCredential.mockResolvedValue(onChainData); + + const result = await service.verify('tx-hash'); + + expect(result).toEqual({ credential, ...onChainData }); + expect(mockRepository.findByTxHash).toHaveBeenCalledWith('tx-hash'); + expect(mockStellarService.verifyCredential).toHaveBeenCalledWith('tx-hash'); + }); + }); +}); \ No newline at end of file diff --git a/apps/backend/src/credentials/credentials.module.ts b/apps/backend/src/credentials/credentials.module.ts index 9cefd5f5..c5b7bbc9 100644 --- a/apps/backend/src/credentials/credentials.module.ts +++ b/apps/backend/src/credentials/credentials.module.ts @@ -4,9 +4,10 @@ import { Credential } from './credential.entity'; import { CredentialsService } from './credentials.service'; import { CredentialsController } from './credentials.controller'; import { StellarModule } from '../stellar/stellar.module'; +import { RepositoriesModule } from '../repositories/repositories.module'; @Module({ - imports: [TypeOrmModule.forFeature([Credential]), StellarModule], + imports: [TypeOrmModule.forFeature([Credential]), StellarModule, RepositoriesModule], providers: [CredentialsService], controllers: [CredentialsController], exports: [CredentialsService], diff --git a/apps/backend/src/credentials/credentials.service.ts b/apps/backend/src/credentials/credentials.service.ts index 43735c1d..cd95449c 100644 --- a/apps/backend/src/credentials/credentials.service.ts +++ b/apps/backend/src/credentials/credentials.service.ts @@ -1,19 +1,19 @@ -import { Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { Injectable, Inject } from '@nestjs/common'; import { Credential } from './credential.entity'; import { StellarService } from '../stellar/stellar.service'; +import { CredentialsRepository } from '../repositories/credentials-repository.interface'; +import { CREDENTIALS_REPOSITORY_TOKEN } from '../repositories/repositories.module'; @Injectable() export class CredentialsService { constructor( - @InjectRepository(Credential) private repo: Repository, + @Inject(CREDENTIALS_REPOSITORY_TOKEN) private repo: CredentialsRepository, private stellarService: StellarService, ) {} async issue(userId: string, courseId: string, stellarPublicKey: string): Promise { // Avoid duplicate credentials - const existing = await this.repo.findOne({ where: { userId, courseId } }); + const existing = await this.repo.findByUserAndCourse(userId, courseId); if (existing) return existing; const txHash = await this.stellarService.issueCredential(stellarPublicKey, courseId); @@ -25,8 +25,7 @@ export class CredentialsService { // Non-fatal } - const credential = this.repo.create({ userId, courseId, txHash, stellarPublicKey }); - return this.repo.save(credential); + return this.repo.save({ userId, courseId, txHash, stellarPublicKey }); } findByUser(userId: string) { @@ -34,7 +33,7 @@ export class CredentialsService { } async verify(txHash: string) { - const credential = await this.repo.findOne({ where: { txHash } }); + const credential = await this.repo.findByTxHash(txHash); const onChain = await this.stellarService.verifyCredential(txHash); return { credential, ...onChain }; } diff --git a/apps/backend/src/repositories/base-repository.interface.ts b/apps/backend/src/repositories/base-repository.interface.ts new file mode 100644 index 00000000..b16bdb30 --- /dev/null +++ b/apps/backend/src/repositories/base-repository.interface.ts @@ -0,0 +1,5 @@ +export interface BaseRepository { + findById(id: string): Promise; + save(entity: Partial): Promise; + remove(entity: T): Promise; +} \ No newline at end of file diff --git a/apps/backend/src/repositories/courses-repository.interface.ts b/apps/backend/src/repositories/courses-repository.interface.ts new file mode 100644 index 00000000..4decf607 --- /dev/null +++ b/apps/backend/src/repositories/courses-repository.interface.ts @@ -0,0 +1,13 @@ +import { Course } from '../courses/course.entity'; +import { CourseQueryDto } from '../courses/dto/course-query.dto'; +import { BaseRepository } from './base-repository.interface'; + +export interface CoursesRepository extends BaseRepository { + findAll(query?: CourseQueryDto): Promise<{ + data: Course[]; + total: number; + page: number; + limit: number; + }>; + findByIdWithDeleted(id: string): Promise; +} \ No newline at end of file diff --git a/apps/backend/src/repositories/credentials-repository.interface.ts b/apps/backend/src/repositories/credentials-repository.interface.ts new file mode 100644 index 00000000..ef32ea5c --- /dev/null +++ b/apps/backend/src/repositories/credentials-repository.interface.ts @@ -0,0 +1,8 @@ +import { Credential } from '../credentials/credential.entity'; +import { BaseRepository } from './base-repository.interface'; + +export interface CredentialsRepository extends BaseRepository { + findByUser(userId: string): Promise; + findByUserAndCourse(userId: string, courseId: string): Promise; + findByTxHash(txHash: string): Promise; +} \ No newline at end of file diff --git a/apps/backend/src/repositories/index.ts b/apps/backend/src/repositories/index.ts new file mode 100644 index 00000000..846e176e --- /dev/null +++ b/apps/backend/src/repositories/index.ts @@ -0,0 +1,8 @@ +export * from './base-repository.interface'; +export * from './users-repository.interface'; +export * from './courses-repository.interface'; +export * from './credentials-repository.interface'; +export * from './typeorm-users.repository'; +export * from './typeorm-courses.repository'; +export * from './typeorm-credentials.repository'; +export * from './repositories.module'; \ No newline at end of file diff --git a/apps/backend/src/repositories/repositories.module.ts b/apps/backend/src/repositories/repositories.module.ts new file mode 100644 index 00000000..dd3dad0d --- /dev/null +++ b/apps/backend/src/repositories/repositories.module.ts @@ -0,0 +1,36 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { User } from '../users/user.entity'; +import { Course } from '../courses/course.entity'; +import { Credential } from '../credentials/credential.entity'; +import { TypeOrmUsersRepository } from './typeorm-users.repository'; +import { TypeOrmCoursesRepository } from './typeorm-courses.repository'; +import { TypeOrmCredentialsRepository } from './typeorm-credentials.repository'; + +export const USERS_REPOSITORY_TOKEN = 'USERS_REPOSITORY'; +export const COURSES_REPOSITORY_TOKEN = 'COURSES_REPOSITORY'; +export const CREDENTIALS_REPOSITORY_TOKEN = 'CREDENTIALS_REPOSITORY'; + +@Module({ + imports: [TypeOrmModule.forFeature([User, Course, Credential])], + providers: [ + { + provide: USERS_REPOSITORY_TOKEN, + useClass: TypeOrmUsersRepository, + }, + { + provide: COURSES_REPOSITORY_TOKEN, + useClass: TypeOrmCoursesRepository, + }, + { + provide: CREDENTIALS_REPOSITORY_TOKEN, + useClass: TypeOrmCredentialsRepository, + }, + ], + exports: [ + USERS_REPOSITORY_TOKEN, + COURSES_REPOSITORY_TOKEN, + CREDENTIALS_REPOSITORY_TOKEN, + ], +}) +export class RepositoriesModule {} \ No newline at end of file diff --git a/apps/backend/src/repositories/typeorm-courses.repository.ts b/apps/backend/src/repositories/typeorm-courses.repository.ts new file mode 100644 index 00000000..a38ef2f3 --- /dev/null +++ b/apps/backend/src/repositories/typeorm-courses.repository.ts @@ -0,0 +1,56 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Course } from '../courses/course.entity'; +import { CourseQueryDto } from '../courses/dto/course-query.dto'; +import { CoursesRepository } from './courses-repository.interface'; + +@Injectable() +export class TypeOrmCoursesRepository implements CoursesRepository { + constructor(@InjectRepository(Course) private repo: Repository) {} + + findById(id: string): Promise { + return this.repo.findOne({ where: { id, isDeleted: false } }); + } + + findByIdWithDeleted(id: string): Promise { + return this.repo.findOne({ where: { id } }); + } + + save(data: Partial): Promise { + if (data.id) { + return this.repo.save(data); + } + return this.repo.save(this.repo.create(data)); + } + + remove(entity: Course): Promise { + return this.repo.remove(entity); + } + + async findAll(query: CourseQueryDto = {}) { + const { search, level, page = 1, limit = 20 } = query; + + const qb = this.repo.createQueryBuilder('course') + .where('course.isPublished = :isPublished', { isPublished: true }) + .andWhere('course.isDeleted = :isDeleted', { isDeleted: false }); + + if (search) { + qb.andWhere( + '(course.title ILIKE :search OR course.description ILIKE :search)', + { search: `%${search}%` }, + ); + } + + if (level) { + qb.andWhere('course.level = :level', { level }); + } + + const offset = (page - 1) * limit; + qb.skip(offset).take(limit).orderBy('course.createdAt', 'DESC'); + + const [data, total] = await qb.getManyAndCount(); + + return { data, total, page, limit }; + } +} \ No newline at end of file diff --git a/apps/backend/src/repositories/typeorm-credentials.repository.ts b/apps/backend/src/repositories/typeorm-credentials.repository.ts new file mode 100644 index 00000000..f0acac6c --- /dev/null +++ b/apps/backend/src/repositories/typeorm-credentials.repository.ts @@ -0,0 +1,37 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Credential } from '../credentials/credential.entity'; +import { CredentialsRepository } from './credentials-repository.interface'; + +@Injectable() +export class TypeOrmCredentialsRepository implements CredentialsRepository { + constructor(@InjectRepository(Credential) private repo: Repository) {} + + findById(id: string): Promise { + return this.repo.findOne({ where: { id } }); + } + + save(data: Partial): Promise { + if (data.id) { + return this.repo.save(data); + } + return this.repo.save(this.repo.create(data)); + } + + remove(entity: Credential): Promise { + return this.repo.remove(entity); + } + + findByUser(userId: string): Promise { + return this.repo.find({ where: { userId }, order: { issuedAt: 'DESC' } }); + } + + findByUserAndCourse(userId: string, courseId: string): Promise { + return this.repo.findOne({ where: { userId, courseId } }); + } + + findByTxHash(txHash: string): Promise { + return this.repo.findOne({ where: { txHash } }); + } +} \ No newline at end of file diff --git a/apps/backend/src/repositories/typeorm-users.repository.ts b/apps/backend/src/repositories/typeorm-users.repository.ts new file mode 100644 index 00000000..2a098749 --- /dev/null +++ b/apps/backend/src/repositories/typeorm-users.repository.ts @@ -0,0 +1,75 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { User } from '../users/user.entity'; +import { UsersRepository } from './users-repository.interface'; + +@Injectable() +export class TypeOrmUsersRepository implements UsersRepository { + constructor(@InjectRepository(User) private repo: Repository) {} + + findByEmail(email: string): Promise { + return this.repo.findOne({ where: { email } }); + } + + findByVerificationToken(hash: string): Promise { + return this.repo.findOne({ where: { verificationToken: hash } }); + } + + findById(id: string): Promise { + return this.repo.findOne({ where: { id } }); + } + + save(data: Partial): Promise { + if (data.id) { + return this.repo.save(data); + } + return this.repo.save(this.repo.create(data)); + } + + remove(entity: User): Promise { + return this.repo.remove(entity); + } + + async findAll(options: { + page?: number; + limit?: number; + role?: string; + isVerified?: boolean; + search?: string; + } = {}) { + const { page = 1, limit = 10, role, isVerified, search } = options; + + const query = this.repo.createQueryBuilder('user'); + + if (role) { + query.andWhere('user.role = :role', { role }); + } + + if (isVerified !== undefined) { + query.andWhere('user.isVerified = :isVerified', { isVerified }); + } + + if (search) { + query.andWhere('user.email ILIKE :search', { search: `%${search}%` }); + } + + query.andWhere('user.deletedAt IS NULL'); + + const [users, total] = await query + .skip((page - 1) * limit) + .take(limit) + .orderBy('user.createdAt', 'DESC') + .getManyAndCount(); + + return { + data: users, + meta: { + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }, + }; + } +} \ No newline at end of file diff --git a/apps/backend/src/repositories/users-repository.interface.ts b/apps/backend/src/repositories/users-repository.interface.ts new file mode 100644 index 00000000..16c7585a --- /dev/null +++ b/apps/backend/src/repositories/users-repository.interface.ts @@ -0,0 +1,22 @@ +import { User } from '../users/user.entity'; +import { BaseRepository } from './base-repository.interface'; + +export interface UsersRepository extends BaseRepository { + findByEmail(email: string): Promise; + findByVerificationToken(hash: string): Promise; + findAll(options: { + page?: number; + limit?: number; + role?: string; + isVerified?: boolean; + search?: string; + }): Promise<{ + data: User[]; + meta: { + total: number; + page: number; + limit: number; + totalPages: number; + }; + }>; +} \ No newline at end of file diff --git a/apps/backend/src/users/__tests__/users.service.repository.spec.ts b/apps/backend/src/users/__tests__/users.service.repository.spec.ts new file mode 100644 index 00000000..1d36c6cb --- /dev/null +++ b/apps/backend/src/users/__tests__/users.service.repository.spec.ts @@ -0,0 +1,130 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { NotFoundException } from '@nestjs/common'; +import { UsersService } from '../users.service'; +import { UsersRepository } from '../../repositories/users-repository.interface'; +import { USERS_REPOSITORY_TOKEN } from '../../repositories/repositories.module'; +import { User } from '../user.entity'; + +describe('UsersService', () => { + let service: UsersService; + let mockRepository: jest.Mocked; + + beforeEach(async () => { + mockRepository = { + findById: jest.fn(), + findByEmail: jest.fn(), + findByVerificationToken: jest.fn(), + findAll: jest.fn(), + save: jest.fn(), + remove: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + UsersService, + { + provide: USERS_REPOSITORY_TOKEN, + useValue: mockRepository, + }, + ], + }).compile(); + + service = module.get(UsersService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('findById', () => { + it('should return a user when found', async () => { + const user = { id: '1', email: 'test@example.com' } as User; + mockRepository.findById.mockResolvedValue(user); + + const result = await service.findById('1'); + expect(result).toBe(user); + expect(mockRepository.findById).toHaveBeenCalledWith('1'); + }); + + it('should return null when user not found', async () => { + mockRepository.findById.mockResolvedValue(null); + + const result = await service.findById('1'); + expect(result).toBeNull(); + }); + }); + + describe('findByEmail', () => { + it('should return a user when found by email', async () => { + const user = { id: '1', email: 'test@example.com' } as User; + mockRepository.findByEmail.mockResolvedValue(user); + + const result = await service.findByEmail('test@example.com'); + expect(result).toBe(user); + expect(mockRepository.findByEmail).toHaveBeenCalledWith('test@example.com'); + }); + }); + + describe('create', () => { + it('should create and return a user', async () => { + const userData = { email: 'test@example.com', password: 'password' }; + const createdUser = { id: '1', ...userData } as User; + mockRepository.save.mockResolvedValue(createdUser); + + const result = await service.create(userData); + expect(result).toBe(createdUser); + expect(mockRepository.save).toHaveBeenCalledWith(userData); + }); + }); + + describe('update', () => { + it('should update and return a user', async () => { + const user = { id: '1', email: 'test@example.com' } as User; + const updateData = { email: 'updated@example.com' }; + const updatedUser = { ...user, ...updateData } as User; + + mockRepository.findById.mockResolvedValue(user); + mockRepository.save.mockResolvedValue(updatedUser); + + const result = await service.update('1', updateData); + expect(result).toBe(updatedUser); + expect(mockRepository.save).toHaveBeenCalledWith({ ...user, ...updateData }); + }); + + it('should throw NotFoundException when user not found', async () => { + mockRepository.findById.mockResolvedValue(null); + + await expect(service.update('1', { email: 'updated@example.com' })).rejects.toThrow( + NotFoundException, + ); + }); + }); + + describe('findAll', () => { + it('should return paginated users', async () => { + const paginatedResult = { + data: [{ id: '1', email: 'test@example.com' }] as User[], + meta: { total: 1, page: 1, limit: 10, totalPages: 1 }, + }; + mockRepository.findAll.mockResolvedValue(paginatedResult); + + const result = await service.findAll({ page: 1, limit: 10 }); + expect(result).toBe(paginatedResult); + expect(mockRepository.findAll).toHaveBeenCalledWith({ page: 1, limit: 10 }); + }); + }); + + describe('banUser', () => { + it('should ban a user', async () => { + const user = { id: '1', email: 'test@example.com', isBanned: false } as User; + const bannedUser = { ...user, isBanned: true } as User; + + mockRepository.findById.mockResolvedValue(user); + mockRepository.save.mockResolvedValue(bannedUser); + + const result = await service.banUser('1', true); + expect(result).toBe(bannedUser); + expect(mockRepository.save).toHaveBeenCalledWith({ ...user, isBanned: true }); + }); + }); +}); \ No newline at end of file diff --git a/apps/backend/src/users/users.module.ts b/apps/backend/src/users/users.module.ts index 5346ae21..0a9f33fe 100644 --- a/apps/backend/src/users/users.module.ts +++ b/apps/backend/src/users/users.module.ts @@ -4,9 +4,10 @@ import { User } from './user.entity'; import { UsersService } from './users.service'; import { UsersController, AdminUsersController } from './users.controller'; import { StellarModule } from '../stellar/stellar.module'; +import { RepositoriesModule } from '../repositories/repositories.module'; @Module({ - imports: [TypeOrmModule.forFeature([User]), StellarModule], + imports: [TypeOrmModule.forFeature([User]), StellarModule, RepositoriesModule], controllers: [UsersController, AdminUsersController], providers: [UsersService], exports: [UsersService], diff --git a/apps/backend/src/users/users.service.ts b/apps/backend/src/users/users.service.ts index 26281f8f..9b8d41aa 100644 --- a/apps/backend/src/users/users.service.ts +++ b/apps/backend/src/users/users.service.ts @@ -1,11 +1,11 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { Injectable, NotFoundException, Inject } from '@nestjs/common'; import { User } from './user.entity'; +import { UsersRepository } from '../repositories/users-repository.interface'; +import { USERS_REPOSITORY_TOKEN } from '../repositories/repositories.module'; @Injectable() export class UsersService { - constructor(@InjectRepository(User) private repo: Repository) {} + constructor(@Inject(USERS_REPOSITORY_TOKEN) private repo: UsersRepository) {} findByEmail(email: string) { return this.repo.findOne({ where: { email } }); @@ -36,39 +36,7 @@ export class UsersService { isVerified?: boolean; search?: string; } = {}) { - const { page = 1, limit = 10, role, isVerified, search } = options; - - const query = this.repo.createQueryBuilder('user'); - - if (role) { - query.andWhere('user.role = :role', { role }); - } - - if (isVerified !== undefined) { - query.andWhere('user.isVerified = :isVerified', { isVerified }); - } - - if (search) { - query.andWhere('user.email ILIKE :search', { search: `%${search}%` }); - } - - query.andWhere('user.deletedAt IS NULL'); - - const [users, total] = await query - .skip((page - 1) * limit) - .take(limit) - .orderBy('user.createdAt', 'DESC') - .getManyAndCount(); - - return { - data: users, - meta: { - total, - page, - limit, - totalPages: Math.ceil(total / limit), - }, - }; + return this.repo.findAll(options); } async banUser(id: string, isBanned: boolean) {