diff --git a/src/puzzles/category.controller.ts b/src/puzzles/category.controller.ts index 8fc7f680..e33c3703 100644 --- a/src/puzzles/category.controller.ts +++ b/src/puzzles/category.controller.ts @@ -6,14 +6,17 @@ import { Patch, Param, Delete, + Query, ParseUUIDPipe, } from '@nestjs/common'; import { CategoriesService } from './category.service'; import { CreateCategoryDto } from './dto/create-category.dto'; import { UpdateCategoryDto } from './dto/update-category.dto'; +import { CategoryFilterDto, CategoryAnalyticsDto } from './dto/category-filter.dto'; import { Category } from './entities/category.entity'; +import { Puzzle } from './entities/puzzle.entity'; -@Controller('categories') // Base path for category API +@Controller('categories') export class CategoriesController { constructor(private readonly categoriesService: CategoriesService) {} @@ -22,9 +25,36 @@ export class CategoriesController { return this.categoriesService.create(createCategoryDto); } + @Get('hierarchy') + getHierarchy(): Promise { + return this.categoriesService.getHierarchy(); + } + + @Get('recommendations') + getRecommendations( + @Query('limit') limit?: number, + ): Promise { + return this.categoriesService.getRecommendations(limit ? Number(limit) : 5); + } + @Get() - findAll(): Promise { - return this.categoriesService.findAll(); + findAll(@Query() filter: CategoryFilterDto): Promise { + return this.categoriesService.findAll(filter); + } + + @Get(':id/analytics') + getAnalytics( + @Param('id', ParseUUIDPipe) id: string, + ): Promise { + return this.categoriesService.getCategoryAnalytics(id); + } + + @Get(':id/puzzles') + findCategoryPuzzles( + @Param('id', ParseUUIDPipe) id: string, + @Query() filter: CategoryFilterDto, + ): Promise { + return this.categoriesService.searchPuzzlesByCategory(id, filter); } @Get(':id') @@ -44,8 +74,4 @@ export class CategoriesController { remove(@Param('id', ParseUUIDPipe) id: string): Promise { return this.categoriesService.remove(id); } - - // Potentially add endpoints for linking puzzles to categories later if needed - // e.g., POST /categories/:id/puzzles/:puzzleId - // e.g., DELETE /categories/:id/puzzles/:puzzleId } diff --git a/src/puzzles/category.service.ts b/src/puzzles/category.service.ts index c84bae32..629b7b77 100644 --- a/src/puzzles/category.service.ts +++ b/src/puzzles/category.service.ts @@ -1,35 +1,86 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { Injectable, NotFoundException, BadRequestException, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { Repository, IsNull } from 'typeorm'; import { Category } from './entities/category.entity'; import { CreateCategoryDto } from './dto/create-category.dto'; import { UpdateCategoryDto } from './dto/update-category.dto'; -import { Puzzle } from './entities/puzzle.entity'; // Import Puzzle entity for relation management +import { CategoryFilterDto, CategoryAnalyticsDto } from './dto/category-filter.dto'; +import { Puzzle } from './entities/puzzle.entity'; @Injectable() export class CategoriesService { + private readonly logger = new Logger(CategoriesService.name); + constructor( @InjectRepository(Category) - private categoriesRepository: Repository, - @InjectRepository(Puzzle) // Inject Puzzle repository for potential relation operations - private puzzlesRepository: Repository, + private readonly categoriesRepository: Repository, + @InjectRepository(Puzzle) + private readonly puzzlesRepository: Repository, ) {} async create(createCategoryDto: CreateCategoryDto): Promise { - const category = this.categoriesRepository.create(createCategoryDto); + if (createCategoryDto.parentId) { + const parent = await this.categoriesRepository.findOne({ + where: { id: createCategoryDto.parentId }, + }); + if (!parent) { + throw new NotFoundException( + `Parent category with ID "${createCategoryDto.parentId}" not found`, + ); + } + } + + const slug = + createCategoryDto.slug || + createCategoryDto.name + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/(^-|-$)+/g, ''); + + const category = this.categoriesRepository.create({ + ...createCategoryDto, + slug, + displayOrder: createCategoryDto.displayOrder ?? 0, + isActive: createCategoryDto.isActive ?? true, + }); + return this.categoriesRepository.save(category); } - async findAll(): Promise { - return this.categoriesRepository.find({ - // relations: ['puzzles'], // Include puzzles if you want to see them in the list - }); + async findAll(filter?: CategoryFilterDto): Promise { + const qb = this.categoriesRepository.createQueryBuilder('category'); + + if (filter?.isActive !== undefined) { + qb.andWhere('category.isActive = :isActive', { isActive: filter.isActive }); + } + + if (filter?.parentId) { + qb.andWhere('category.parentId = :parentId', { parentId: filter.parentId }); + } + + if (filter?.search) { + qb.andWhere( + '(LOWER(category.name) LIKE LOWER(:search) OR LOWER(category.description) LIKE LOWER(:search))', + { search: `%${filter.search}%` }, + ); + } + + qb.orderBy('category.displayOrder', 'ASC').addOrderBy('category.name', 'ASC'); + + if (filter?.limit) { + qb.take(filter.limit); + } + if (filter?.offset) { + qb.skip(filter.offset); + } + + return qb.getMany(); } async findOne(id: string): Promise { const category = await this.categoriesRepository.findOne({ where: { id }, - // relations: ['puzzles'], // Include puzzles for detailed view + relations: ['parent', 'children', 'puzzles'], }); if (!category) { throw new NotFoundException(`Category with ID "${id}" not found`); @@ -41,36 +92,189 @@ export class CategoriesService { id: string, updateCategoryDto: UpdateCategoryDto, ): Promise { - const category = await this.findOne(id); // Find existing category - Object.assign(category, updateCategoryDto); // Apply updates + const category = await this.findOne(id); + + if (updateCategoryDto.parentId) { + if (updateCategoryDto.parentId === id) { + throw new BadRequestException('A category cannot be its own parent'); + } + const parent = await this.categoriesRepository.findOne({ + where: { id: updateCategoryDto.parentId }, + }); + if (!parent) { + throw new NotFoundException( + `Parent category with ID "${updateCategoryDto.parentId}" not found`, + ); + } + } + + Object.assign(category, updateCategoryDto); return this.categoriesRepository.save(category); } async remove(id: string): Promise { - const category = await this.findOne(id); + await this.findOne(id); + const result = await this.categoriesRepository.delete(id); + if (result.affected === 0) { + throw new NotFoundException(`Category with ID "${id}" not found`); + } + } + + /** + * Builds and returns full hierarchical tree of categories. + */ + async getHierarchy(): Promise { + const allCategories = await this.categoriesRepository.find({ + relations: ['children', 'puzzles'], + order: { displayOrder: 'ASC', name: 'ASC' }, + }); + + const categoryMap = new Map(); + allCategories.forEach((cat) => { + cat.children = []; + categoryMap.set(cat.id, cat); + }); - // Before deleting, consider how to handle associated puzzles. - // For example, you might want to: - // 1. Remove the category from all associated puzzles. - // 2. Prevent deletion if any puzzles are associated with it. - // 3. Cascade delete (less common for categories). + const roots: Category[] = []; + allCategories.forEach((cat) => { + if (cat.parentId && categoryMap.has(cat.parentId)) { + categoryMap.get(cat.parentId)!.children.push(cat); + } else { + roots.push(cat); + } + }); - // For now, we will simply remove the category. If TypeORM is configured with cascade - // options for the relation, it might handle some aspects. - // A more robust approach would be to disassociate puzzles first. + return roots; + } - // Example: Disassociate puzzles - // category.puzzles = []; - // await this.categoriesRepository.save(category); // Save with empty puzzles array + /** + * Collects analytics and popularity score for category. + */ + async getCategoryAnalytics(id?: string): Promise { + const categories = id + ? [await this.findOne(id)] + : await this.categoriesRepository.find({ relations: ['puzzles'] }); - // A simpler approach for now: just remove the category. - const result = await this.categoriesRepository.delete(id); - if (result.affected === 0) { - throw new NotFoundException(`Category with ID "${id}" not found`); + return categories.map((cat) => { + const puzzles = cat.puzzles || []; + const totalPuzzles = puzzles.length; + const totalCompletions = puzzles.reduce( + (sum, p) => sum + (p.completions || 0), + 0, + ); + const totalRating = puzzles.reduce( + (sum, p) => sum + Number(p.averageRating || 0), + 0, + ); + const averageRating = + totalPuzzles > 0 ? Number((totalRating / totalPuzzles).toFixed(2)) : 0; + + const difficultyBreakdown: Record = { + easy: 0, + medium: 0, + hard: 0, + expert: 0, + }; + puzzles.forEach((p) => { + if (p.difficulty && difficultyBreakdown[p.difficulty] !== undefined) { + difficultyBreakdown[p.difficulty]++; + } + }); + + // Popularity formula: completions * 2 + totalPuzzles * 5 + averageRating * 10 + const popularityScore = Math.round( + totalCompletions * 2 + totalPuzzles * 5 + averageRating * 10, + ); + + return { + categoryId: cat.id, + categoryName: cat.name, + totalPuzzles, + totalCompletions, + averageRating, + difficultyBreakdown, + popularityScore, + }; + }); + } + + /** + * Recommends active categories sorted by popularity and content density. + */ + async getRecommendations(limit = 5): Promise { + const analytics = await this.getCategoryAnalytics(); + analytics.sort((a, b) => b.popularityScore - a.popularityScore); + + const topIds = analytics.slice(0, limit).map((a) => a.categoryId); + if (topIds.length === 0) { + return this.categoriesRepository.find({ take: limit }); } + + const categories = await this.categoriesRepository + .createQueryBuilder('category') + .where('category.id IN (:...ids)', { ids: topIds }) + .getMany(); + + // Preserve order of popularity + const catMap = new Map(categories.map((c) => [c.id, c])); + return topIds + .map((tid) => catMap.get(tid)) + .filter((c): c is Category => c !== undefined); + } + + /** + * Searches and filters puzzles within a category and optionally its subcategories. + */ + async searchPuzzlesByCategory( + categoryId: string, + filter?: CategoryFilterDto, + ): Promise { + const category = await this.findOne(categoryId); + + // Collect category ID and any child category IDs if includeChildren is true + let categoryIds = [category.id]; + if (filter?.includeChildren && category.children?.length) { + categoryIds = categoryIds.concat(category.children.map((c) => c.id)); + } + + const qb = this.puzzlesRepository + .createQueryBuilder('puzzle') + .leftJoinAndSelect('puzzle.categories', 'category') + .leftJoinAndSelect('puzzle.tagEntities', 'tag') + .where('category.id IN (:...categoryIds)', { categoryIds }); + + if (filter?.difficulty) { + qb.andWhere('puzzle.difficulty = :difficulty', { + difficulty: filter.difficulty, + }); + } + + if (filter?.tags && filter.tags.length > 0) { + qb.andWhere('tag.name IN (:...tags)', { tags: filter.tags }); + } + + if (filter?.search) { + qb.andWhere( + '(LOWER(puzzle.title) LIKE LOWER(:search) OR LOWER(puzzle.description) LIKE LOWER(:search))', + { search: `%${filter.search}%` }, + ); + } + + qb.orderBy('puzzle.averageRating', 'DESC').addOrderBy( + 'puzzle.createdAt', + 'DESC', + ); + + if (filter?.limit) { + qb.take(filter.limit); + } + if (filter?.offset) { + qb.skip(filter.offset); + } + + return qb.getMany(); } - // Helper method to get repository if needed in other services getCategoryRepository(): Repository { return this.categoriesRepository; } diff --git a/src/puzzles/dto/category-filter.dto.ts b/src/puzzles/dto/category-filter.dto.ts new file mode 100644 index 00000000..efe31e33 --- /dev/null +++ b/src/puzzles/dto/category-filter.dto.ts @@ -0,0 +1,58 @@ +import { IsOptional, IsString, IsUUID, IsBoolean, IsArray, IsInt, Min } from 'class-validator'; +import { Type, Transform } from 'class-transformer'; + +export class CategoryFilterDto { + @IsOptional() + @IsString() + search?: string; + + @IsOptional() + @IsUUID() + parentId?: string; + + @IsOptional() + @IsBoolean() + @Transform(({ value }: any) => (value === 'true' || value === true)) + isActive?: boolean; + + @IsOptional() + @IsBoolean() + @Transform(({ value }: any) => (value === 'true' || value === true)) + includeChildren?: boolean; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + @Transform(({ value }: any) => { + if (typeof value === 'string') return value.split(',').map((v) => v.trim().toLowerCase()); + if (Array.isArray(value)) return value.map((v) => String(v).trim().toLowerCase()); + return value; + }) + tags?: string[]; + + @IsOptional() + @IsString() + difficulty?: string; + + @IsOptional() + @IsInt() + @Min(1) + @Type(() => Number) + limit?: number = 20; + + @IsOptional() + @IsInt() + @Min(0) + @Type(() => Number) + offset?: number = 0; +} + +export interface CategoryAnalyticsDto { + categoryId: string; + categoryName: string; + totalPuzzles: number; + totalCompletions: number; + averageRating: number; + difficultyBreakdown: Record; + popularityScore: number; +} diff --git a/src/puzzles/dto/create-category.dto.ts b/src/puzzles/dto/create-category.dto.ts index cf1975d4..b63b6462 100644 --- a/src/puzzles/dto/create-category.dto.ts +++ b/src/puzzles/dto/create-category.dto.ts @@ -4,13 +4,25 @@ export class CreateCategoryDto { @IsString() name: string; + @IsOptional() + @IsString() + slug?: string; + @IsOptional() @IsString() description?: string; - // We might not need to link puzzles or collections directly during creation, - // as those can be managed via separate endpoints or by updating the puzzle/collection. - // However, if the schema implies a direct link that must be set at creation, - // it would be added here (e.g., @IsArray(), @IsUUID() for puzzleIds). - // For now, keeping it simple for basic category creation. + @IsOptional() + @IsString() + icon?: string; + + @IsOptional() + @IsUUID() + parentId?: string; + + @IsOptional() + displayOrder?: number; + + @IsOptional() + isActive?: boolean; } diff --git a/src/puzzles/entities/category.entity.ts b/src/puzzles/entities/category.entity.ts index 656eddb7..62b30f24 100644 --- a/src/puzzles/entities/category.entity.ts +++ b/src/puzzles/entities/category.entity.ts @@ -3,6 +3,9 @@ import { PrimaryGeneratedColumn, Column, ManyToMany, + ManyToOne, + OneToMany, + JoinColumn, JoinTable, } from 'typeorm'; import { Puzzle } from './puzzle.entity'; @@ -16,9 +19,34 @@ export class Category { @Column({ unique: true }) name: string; + @Column({ nullable: true }) + slug?: string; + @Column({ nullable: true }) description?: string; + @Column({ nullable: true }) + icon?: string; + + @Column({ type: 'int', default: 0 }) + displayOrder: number; + + @Column({ type: 'boolean', default: true }) + isActive: boolean; + + @Column({ type: 'uuid', nullable: true }) + parentId?: string; + + @ManyToOne(() => Category, (category) => category.children, { + nullable: true, + onDelete: 'SET NULL', + }) + @JoinColumn({ name: 'parentId' }) + parent?: Category; + + @OneToMany(() => Category, (category) => category.parent) + children: Category[]; + @ManyToMany(() => Puzzle, (puzzle) => puzzle.categories) @JoinTable() puzzles: Puzzle[]; diff --git a/src/puzzles/tags.controller.ts b/src/puzzles/tags.controller.ts index bc805a8f..aa0e8317 100644 --- a/src/puzzles/tags.controller.ts +++ b/src/puzzles/tags.controller.ts @@ -21,7 +21,7 @@ export class TagsController { /** * POST /tags - * Create a new tag (admin/editor only — guard can be added when auth is wired up). + * Create a new tag. */ @Post() async create(@Body() dto: CreateTagDto): Promise { @@ -29,6 +29,31 @@ export class TagsController { return this.tagsService.createTag(dto); } + /** + * GET /tags/trending + * List trending tags ordered by usageCount DESC. + */ + @Get('trending') + async getTrending(@Query('limit') limit?: number): Promise { + return this.tagsService.getTrendingTags(limit ? Number(limit) : 10); + } + + /** + * GET /tags/recommendations + * Recommend related tags given seed tags. + */ + @Get('recommendations') + async getRecommendations( + @Query('seeds') seeds?: string, + @Query('limit') limit?: number, + ): Promise { + const seedArray = seeds ? seeds.split(',').map((s) => s.trim()) : []; + return this.tagsService.getRecommendedTags( + seedArray, + limit ? Number(limit) : 5, + ); + } + /** * GET /tags * List all tags with usageCount, sortable by name or usageCount. diff --git a/src/puzzles/tags.service.ts b/src/puzzles/tags.service.ts index 15bc2bab..fa28fa70 100644 --- a/src/puzzles/tags.service.ts +++ b/src/puzzles/tags.service.ts @@ -190,4 +190,64 @@ export class TagsService { await queryRunner.release(); } } + + /** + * Get trending tags ordered by usageCount DESC. + */ + async getTrendingTags(limit = 10): Promise { + return this.tagRepository.find({ + order: { usageCount: 'DESC', name: 'ASC' }, + take: limit, + }); + } + + /** + * Recommend related tags based on provided seed tags. + */ + async getRecommendedTags( + seedTagNames: string[], + limit = 5, + ): Promise { + const normalised = seedTagNames.map((t) => t.trim().toLowerCase()); + const qb = this.tagRepository.createQueryBuilder('tag'); + + if (normalised.length > 0) { + qb.where('tag.name NOT IN (:...normalised)', { normalised }); + } + + return qb + .orderBy('tag.usageCount', 'DESC') + .addOrderBy('tag.name', 'ASC') + .take(limit) + .getMany(); + } + + /** + * Filter puzzles matching multiple tags (supporting ANY or ALL semantics). + */ + async filterPuzzlesByMultiTags( + tagNames: string[], + matchAll = false, + ): Promise { + if (!tagNames || tagNames.length === 0) { + return []; + } + + const normalised = tagNames.map((t) => t.trim().toLowerCase()); + + const qb = this.puzzleRepository + .createQueryBuilder('puzzle') + .innerJoinAndSelect('puzzle.tagEntities', 'tag') + .where('tag.name IN (:...tags)', { tags: normalised }); + + if (matchAll && normalised.length > 1) { + qb.groupBy('puzzle.id') + .having('COUNT(DISTINCT tag.id) >= :count', { + count: normalised.length, + }); + } + + return qb.getMany(); + } } + diff --git a/src/puzzles/tests/puzzle-categorization-tags.spec.ts b/src/puzzles/tests/puzzle-categorization-tags.spec.ts new file mode 100644 index 00000000..bad43b4a --- /dev/null +++ b/src/puzzles/tests/puzzle-categorization-tags.spec.ts @@ -0,0 +1,300 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { NotFoundException, BadRequestException } from '@nestjs/common'; +import { CategoriesService } from '../category.service'; +import { TagsService } from '../tags.service'; +import { Category } from '../entities/category.entity'; +import { Puzzle } from '../entities/puzzle.entity'; +import { Tag } from '../entities/tag.entity'; +import { DataSource } from 'typeorm'; + +describe('Puzzle Categorization and Tag System', () => { + let categoriesService: CategoriesService; + let tagsService: TagsService; + let categoryRepo: jest.Mocked>; + let puzzleRepo: jest.Mocked>; + let tagRepo: jest.Mocked>; + + const mockCategory = (overrides: Partial = {}): Category => + ({ + id: 'cat-uuid-1', + name: 'Logic Puzzles', + slug: 'logic-puzzles', + description: 'Puzzles that test deductive logic', + icon: 'brain-icon', + displayOrder: 1, + isActive: true, + parentId: null, + children: [], + puzzles: [], + collections: [], + ...overrides, + } as unknown as Category); + + const mockPuzzle = (overrides: Partial = {}): Puzzle => + ({ + id: 'puzzle-uuid-1', + title: 'Sudoku Master', + description: 'Classic 9x9 logic grid', + difficulty: 'medium', + completions: 25, + averageRating: 4.8, + categories: [], + tagEntities: [], + ...overrides, + } as unknown as Puzzle); + + const mockTag = (overrides: Partial = {}): Tag => + ({ + id: 'tag-uuid-1', + name: 'logic', + usageCount: 42, + createdAt: new Date(), + updatedAt: new Date(), + puzzles: [], + ...overrides, + } as unknown as Tag); + + beforeEach(async () => { + const mockCategoryRepo = { + create: jest.fn().mockImplementation((dto) => dto), + save: jest.fn().mockImplementation((cat) => Promise.resolve({ id: 'cat-uuid-new', ...cat })), + findOne: jest.fn(), + find: jest.fn(), + delete: jest.fn(), + createQueryBuilder: jest.fn(), + }; + + const mockPuzzleRepo = { + findOne: jest.fn(), + find: jest.fn(), + save: jest.fn(), + createQueryBuilder: jest.fn(), + }; + + const mockTagRepo = { + create: jest.fn().mockImplementation((dto) => dto), + save: jest.fn().mockImplementation((t) => Promise.resolve({ id: 'tag-new', ...t })), + findOne: jest.fn(), + find: jest.fn(), + createQueryBuilder: jest.fn(), + }; + + const mockDataSource = { + createQueryRunner: jest.fn().mockReturnValue({ + connect: jest.fn(), + startTransaction: jest.fn(), + commitTransaction: jest.fn(), + rollbackTransaction: jest.fn(), + release: jest.fn(), + manager: { + findOne: jest.fn(), + create: jest.fn(), + save: jest.fn(), + increment: jest.fn(), + decrement: jest.fn(), + }, + }), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + CategoriesService, + TagsService, + { provide: getRepositoryToken(Category), useValue: mockCategoryRepo }, + { provide: getRepositoryToken(Puzzle), useValue: mockPuzzleRepo }, + { provide: getRepositoryToken(Tag), useValue: mockTagRepo }, + { provide: DataSource, useValue: mockDataSource }, + ], + }).compile(); + + categoriesService = module.get(CategoriesService); + tagsService = module.get(TagsService); + categoryRepo = module.get(getRepositoryToken(Category)); + puzzleRepo = module.get(getRepositoryToken(Puzzle)); + tagRepo = module.get(getRepositoryToken(Tag)); + }); + + describe('CategoriesService Hierarchy & Management', () => { + it('creates category and automatically generates normalized slug', async () => { + categoryRepo.findOne.mockResolvedValue(null); + + const result = await categoriesService.create({ + name: 'Pattern Recognition', + description: 'Visual pattern tests', + }); + + expect(categoryRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Pattern Recognition', + slug: 'pattern-recognition', + displayOrder: 0, + isActive: true, + }), + ); + expect(result.name).toBe('Pattern Recognition'); + }); + + it('validates parentId when creating child category', async () => { + categoryRepo.findOne.mockResolvedValue(null); + + await expect( + categoriesService.create({ + name: 'Binary Logic', + parentId: 'non-existent-parent', + }), + ).rejects.toThrow(NotFoundException); + }); + + it('prevents self-referencing parentId on category update', async () => { + const cat = mockCategory({ id: 'cat-1' }); + categoryRepo.findOne.mockResolvedValue(cat); + + await expect( + categoriesService.update('cat-1', { parentId: 'cat-1' }), + ).rejects.toThrow(BadRequestException); + }); + + it('builds full category hierarchy tree correctly', async () => { + const root1 = mockCategory({ id: 'root-1', name: 'Mathematics', parentId: null }); + const child1 = mockCategory({ id: 'child-1', name: 'Algebra', parentId: 'root-1' }); + const child2 = mockCategory({ id: 'child-2', name: 'Geometry', parentId: 'root-1' }); + const root2 = mockCategory({ id: 'root-2', name: 'Word Games', parentId: null }); + + categoryRepo.find.mockResolvedValue([root1, child1, child2, root2]); + + const hierarchy = await categoriesService.getHierarchy(); + + expect(hierarchy.length).toBe(2); + expect(hierarchy[0].id).toBe('root-1'); + expect(hierarchy[0].children.length).toBe(2); + expect(hierarchy[0].children[0].id).toBe('child-1'); + expect(hierarchy[0].children[1].id).toBe('child-2'); + expect(hierarchy[1].id).toBe('root-2'); + expect(hierarchy[1].children.length).toBe(0); + }); + + it('calculates category analytics and popularity score accurately', async () => { + const puzzle1 = mockPuzzle({ + difficulty: 'easy', + completions: 10, + averageRating: 4.0, + }); + const puzzle2 = mockPuzzle({ + difficulty: 'hard', + completions: 30, + averageRating: 5.0, + }); + const cat = mockCategory({ + id: 'cat-logic', + name: 'Logic', + puzzles: [puzzle1, puzzle2], + }); + + categoryRepo.findOne.mockResolvedValue(cat); + + const [analytics] = await categoriesService.getCategoryAnalytics('cat-logic'); + + expect(analytics.categoryId).toBe('cat-logic'); + expect(analytics.totalPuzzles).toBe(2); + expect(analytics.totalCompletions).toBe(40); + expect(analytics.averageRating).toBe(4.5); + expect(analytics.difficultyBreakdown).toEqual({ + easy: 1, + medium: 0, + hard: 1, + expert: 0, + }); + // Popularity formula: 40 * 2 + 2 * 5 + 4.5 * 10 = 80 + 10 + 45 = 135 + expect(analytics.popularityScore).toBe(135); + }); + + it('ranks category recommendations by popularity score', async () => { + const catA = mockCategory({ + id: 'cat-a', + name: 'High Pop', + puzzles: [mockPuzzle({ completions: 100, averageRating: 5.0 })], + }); + const catB = mockCategory({ + id: 'cat-b', + name: 'Low Pop', + puzzles: [mockPuzzle({ completions: 2, averageRating: 3.0 })], + }); + + categoryRepo.find.mockResolvedValue([catB, catA]); + + const mockQb = { + where: jest.fn().mockReturnThis(), + getMany: jest.fn().mockResolvedValue([catA, catB]), + }; + categoryRepo.createQueryBuilder.mockReturnValue(mockQb as any); + + const recommendations = await categoriesService.getRecommendations(2); + + expect(recommendations.length).toBe(2); + expect(recommendations[0].id).toBe('cat-a'); + expect(recommendations[1].id).toBe('cat-b'); + }); + }); + + describe('TagsService Multi-Tags & Trending', () => { + it('retrieves trending tags ordered by usageCount DESC', async () => { + const tag1 = mockTag({ name: 'cryptography', usageCount: 150 }); + const tag2 = mockTag({ name: 'algorithms', usageCount: 85 }); + + tagRepo.find.mockResolvedValue([tag1, tag2]); + + const trending = await tagsService.getTrendingTags(10); + + expect(tagRepo.find).toHaveBeenCalledWith({ + order: { usageCount: 'DESC', name: 'ASC' }, + take: 10, + }); + expect(trending).toEqual([tag1, tag2]); + expect(trending[0].usageCount).toBe(150); + }); + + it('recommends related tags excluding provided seed tags', async () => { + const mockQb = { + where: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + addOrderBy: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + getMany: jest.fn().mockResolvedValue([mockTag({ name: 'sorting' })]), + }; + tagRepo.createQueryBuilder.mockReturnValue(mockQb as any); + + const recommended = await tagsService.getRecommendedTags(['logic', 'math'], 5); + + expect(mockQb.where).toHaveBeenCalledWith('tag.name NOT IN (:...normalised)', { + normalised: ['logic', 'math'], + }); + expect(recommended.length).toBe(1); + }); + + it('filters puzzles by multiple tags', async () => { + const mockQb = { + innerJoinAndSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + groupBy: jest.fn().mockReturnThis(), + having: jest.fn().mockReturnThis(), + getMany: jest.fn().mockResolvedValue([mockPuzzle()]), + }; + puzzleRepo.createQueryBuilder.mockReturnValue(mockQb as any); + + const results = await tagsService.filterPuzzlesByMultiTags( + ['logic', 'math'], + true, + ); + + expect(mockQb.where).toHaveBeenCalledWith('tag.name IN (:...tags)', { + tags: ['logic', 'math'], + }); + expect(mockQb.having).toHaveBeenCalledWith('COUNT(DISTINCT tag.id) >= :count', { + count: 2, + }); + expect(results.length).toBe(1); + }); + }); +});