Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 129 additions & 0 deletions apps/backend/src/courses/__tests__/courses.service.repository.spec.ts
Original file line number Diff line number Diff line change
@@ -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<CoursesRepository>;
let mockCacheManager: jest.Mocked<any>;

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>(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');
});
});
});
3 changes: 2 additions & 1 deletion apps/backend/src/courses/courses.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down
39 changes: 7 additions & 32 deletions apps/backend/src/courses/courses.service.ts
Original file line number Diff line number Diff line change
@@ -1,73 +1,48 @@
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 {
private readonly CACHE_KEY = 'courses:all';
private readonly CACHE_TTL = 60;

constructor(
@InjectRepository(Course) private repo: Repository<Course>,
@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<Course> {
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<Course>) {
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<Course>) {
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;
}

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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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<CredentialsRepository>;
let mockStellarService: jest.Mocked<StellarService>;

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>(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');
});
});
});
3 changes: 2 additions & 1 deletion apps/backend/src/credentials/credentials.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down
Loading
Loading