import { Test, TestingModule } from '@nestjs/testing'; import { NotFoundException, BadRequestException, } from '@nestjs/common'; import { ShareService } from './share.service'; import { ShareConfigRepository } from './repositories/share-config.repository'; import { ShareParticipantRepository } from './repositories/share-participant.repository'; import { ShareLevelProgressRepository } from './repositories/share-level-progress.repository'; import { LevelRepository } from '../wechat-game/repositories/level.repository'; import { Level } from '../wechat-game/entities/level.entity'; import { ShareConfig } from './entities/share-config.entity'; import { ShareParticipant } from './entities/share-participant.entity'; import { ShareLevelProgress } from './entities/share-level-progress.entity'; // Mock nanoid to return predictable values jest.mock('nanoid', () => ({ nanoid: jest.fn(() => 'ABCD1234'), })); describe('ShareService', () => { let service: ShareService; const mockLevels: Level[] = Array.from({ length: 6 }, (_, i) => ({ id: `level-${i + 1}`, imageUrl: `https://example.com/meme${i + 1}.jpg`, answer: `答案${i + 1}`, hint1: `提示${i + 1}`, hint2: null, hint3: null, sortOrder: i, timeLimit: i === 0 ? 60 : null, createdAt: new Date('2026-01-01'), updatedAt: new Date('2026-01-01'), })); const mockShareConfig: ShareConfig = { id: 'share-uuid-1', shareCode: 'ABCD1234', title: '我的挑战', sharerId: 'user-uuid-1', levelIds: mockLevels.map((l) => l.id), sharer: {} as any, participants: [], createdAt: new Date('2026-01-01'), updatedAt: new Date('2026-01-01'), }; const mockParticipant: ShareParticipant = { id: 'participant-uuid-1', shareConfigId: 'share-uuid-1', participantId: 'user-uuid-2', shareConfig: {} as any, participant: {} as any, createdAt: new Date('2026-01-01'), }; const mockShareConfigRepository = { create: jest.fn(), findByShareCode: jest.fn(), }; const mockShareParticipantRepository = { addParticipant: jest.fn(), countByShareConfigId: jest.fn(), findByShareConfigAndParticipant: jest.fn(), create: jest.fn(), save: jest.fn(), }; const mockShareLevelProgressRepository = { findByParticipantAndLevel: jest.fn(), create: jest.fn(), save: jest.fn(), }; const mockLevelRepository = { findByIds: jest.fn(), findById: jest.fn(), }; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [ ShareService, { provide: ShareConfigRepository, useValue: mockShareConfigRepository }, { provide: ShareParticipantRepository, useValue: mockShareParticipantRepository, }, { provide: ShareLevelProgressRepository, useValue: mockShareLevelProgressRepository, }, { provide: LevelRepository, useValue: mockLevelRepository }, ], }).compile(); service = module.get(ShareService); }); afterEach(() => { jest.clearAllMocks(); }); describe('createShare', () => { const createDto = { title: '我的挑战', levelIds: ['level-1', 'level-2', 'level-3', 'level-4', 'level-5', 'level-6'], }; it('should create a share with valid 6 unique levels', async () => { mockLevelRepository.findByIds.mockResolvedValue(mockLevels); mockShareConfigRepository.findByShareCode.mockResolvedValue(null); mockShareConfigRepository.create.mockResolvedValue(mockShareConfig); const result = await service.createShare('user-uuid-1', createDto); expect(result.shareCode).toBe('ABCD1234'); expect(result.title).toBe('我的挑战'); expect(result.levelCount).toBe(6); }); it('should throw BadRequestException when level IDs have duplicates', async () => { const duplicateDto = { title: '测试', levelIds: ['level-1', 'level-1', 'level-2', 'level-3', 'level-4', 'level-5'], }; await expect( service.createShare('user-uuid-1', duplicateDto), ).rejects.toThrow(BadRequestException); }); it('should throw NotFoundException when some levels do not exist', async () => { mockLevelRepository.findByIds.mockResolvedValue(mockLevels.slice(0, 4)); await expect( service.createShare('user-uuid-1', createDto), ).rejects.toThrow(NotFoundException); }); it('should throw BadRequestException when share code generation fails', async () => { mockLevelRepository.findByIds.mockResolvedValue(mockLevels); // All 3 attempts find existing codes mockShareConfigRepository.findByShareCode.mockResolvedValue( mockShareConfig, ); await expect( service.createShare('user-uuid-1', createDto), ).rejects.toThrow(BadRequestException); }); }); describe('joinShare', () => { it('should return share details with ordered levels for a participant', async () => { mockShareConfigRepository.findByShareCode.mockResolvedValue( mockShareConfig, ); mockShareParticipantRepository.addParticipant.mockResolvedValue(undefined); mockLevelRepository.findByIds.mockResolvedValue(mockLevels); const result = await service.joinShare('user-uuid-2', 'ABCD1234'); expect(result.shareCode).toBe('ABCD1234'); expect(result.title).toBe('我的挑战'); expect(result.levels).toHaveLength(6); expect(result.levels[0].level).toBe(1); expect(result.levels[0].id).toBe('level-1'); expect(mockShareParticipantRepository.addParticipant).toHaveBeenCalledWith( 'share-uuid-1', 'user-uuid-2', ); }); it('should not add participant when user is the sharer', async () => { mockShareConfigRepository.findByShareCode.mockResolvedValue( mockShareConfig, ); mockLevelRepository.findByIds.mockResolvedValue(mockLevels); const result = await service.joinShare('user-uuid-1', 'ABCD1234'); expect(result.shareCode).toBe('ABCD1234'); expect( mockShareParticipantRepository.addParticipant, ).not.toHaveBeenCalled(); }); it('should throw NotFoundException when share code not found', async () => { mockShareConfigRepository.findByShareCode.mockResolvedValue(null); await expect( service.joinShare('user-uuid-2', 'INVALID'), ).rejects.toThrow(NotFoundException); }); }); describe('reportLevelProgress', () => { const reportDto = { shareCode: 'ABCD1234', levelId: 'level-1', passed: true, timeSpent: 30, }; it('should create new progress record for first attempt', async () => { const levelWithTimeLimit = { ...mockLevels[0], timeLimit: 60 }; mockShareConfigRepository.findByShareCode.mockResolvedValue( mockShareConfig, ); mockLevelRepository.findById.mockResolvedValue(levelWithTimeLimit); mockShareParticipantRepository.findByShareConfigAndParticipant.mockResolvedValue( mockParticipant, ); mockShareLevelProgressRepository.findByParticipantAndLevel.mockResolvedValue( null, ); const newProgress: Partial = { participantId: 'participant-uuid-1', levelId: 'level-1', passed: true, timeSpent: 30, }; mockShareLevelProgressRepository.create.mockReturnValue(newProgress); mockShareLevelProgressRepository.save.mockResolvedValue(newProgress); const result = await service.reportLevelProgress( 'user-uuid-2', reportDto, ); expect(result.passed).toBe(true); expect(result.timeLimit).toBe(60); expect(result.withinTimeLimit).toBe(true); }); it('should return existing result when already passed (idempotent)', async () => { const levelWithTimeLimit = { ...mockLevels[0], timeLimit: 60 }; mockShareConfigRepository.findByShareCode.mockResolvedValue( mockShareConfig, ); mockLevelRepository.findById.mockResolvedValue(levelWithTimeLimit); mockShareParticipantRepository.findByShareConfigAndParticipant.mockResolvedValue( mockParticipant, ); mockShareLevelProgressRepository.findByParticipantAndLevel.mockResolvedValue( { id: 'progress-uuid-1', participantId: 'participant-uuid-1', levelId: 'level-1', passed: true, timeSpent: 25, completedAt: new Date(), } as ShareLevelProgress, ); const result = await service.reportLevelProgress( 'user-uuid-2', reportDto, ); expect(result.passed).toBe(true); expect(result.withinTimeLimit).toBe(true); expect(mockShareLevelProgressRepository.save).not.toHaveBeenCalled(); }); it('should report withinTimeLimit=false when time exceeds limit', async () => { const levelWithTimeLimit = { ...mockLevels[0], timeLimit: 20 }; mockShareConfigRepository.findByShareCode.mockResolvedValue( mockShareConfig, ); mockLevelRepository.findById.mockResolvedValue(levelWithTimeLimit); mockShareParticipantRepository.findByShareConfigAndParticipant.mockResolvedValue( mockParticipant, ); mockShareLevelProgressRepository.findByParticipantAndLevel.mockResolvedValue( null, ); const newProgress: Partial = { participantId: 'participant-uuid-1', levelId: 'level-1', passed: true, timeSpent: 30, }; mockShareLevelProgressRepository.create.mockReturnValue(newProgress); mockShareLevelProgressRepository.save.mockResolvedValue(newProgress); const result = await service.reportLevelProgress('user-uuid-2', { ...reportDto, timeSpent: 30, }); expect(result.passed).toBe(true); expect(result.withinTimeLimit).toBe(false); }); it('should report withinTimeLimit=true when level has no time limit', async () => { const levelNoTimeLimit = { ...mockLevels[1], timeLimit: null }; mockShareConfigRepository.findByShareCode.mockResolvedValue( mockShareConfig, ); mockLevelRepository.findById.mockResolvedValue(levelNoTimeLimit); mockShareParticipantRepository.findByShareConfigAndParticipant.mockResolvedValue( mockParticipant, ); mockShareLevelProgressRepository.findByParticipantAndLevel.mockResolvedValue( null, ); const newProgress: Partial = { participantId: 'participant-uuid-1', levelId: 'level-2', passed: true, timeSpent: 999, }; mockShareLevelProgressRepository.create.mockReturnValue(newProgress); mockShareLevelProgressRepository.save.mockResolvedValue(newProgress); const result = await service.reportLevelProgress('user-uuid-2', { shareCode: 'ABCD1234', levelId: 'level-2', passed: true, timeSpent: 999, }); expect(result.withinTimeLimit).toBe(true); }); it('should create participant if not exists when reporting progress', async () => { mockShareConfigRepository.findByShareCode.mockResolvedValue( mockShareConfig, ); mockLevelRepository.findById.mockResolvedValue(mockLevels[0]); mockShareParticipantRepository.findByShareConfigAndParticipant.mockResolvedValue( null, ); const createdParticipant = { ...mockParticipant }; mockShareParticipantRepository.create.mockReturnValue(createdParticipant); mockShareParticipantRepository.save.mockResolvedValue(createdParticipant); mockShareLevelProgressRepository.findByParticipantAndLevel.mockResolvedValue( null, ); mockShareLevelProgressRepository.create.mockReturnValue({} as any); mockShareLevelProgressRepository.save.mockResolvedValue({} as any); await service.reportLevelProgress('user-uuid-2', reportDto); expect(mockShareParticipantRepository.create).toHaveBeenCalledWith({ shareConfigId: 'share-uuid-1', participantId: 'user-uuid-2', }); expect(mockShareParticipantRepository.save).toHaveBeenCalled(); }); it('should throw NotFoundException when share not found', async () => { mockShareConfigRepository.findByShareCode.mockResolvedValue(null); await expect( service.reportLevelProgress('user-uuid-2', reportDto), ).rejects.toThrow(NotFoundException); }); it('should throw NotFoundException when level not found', async () => { mockShareConfigRepository.findByShareCode.mockResolvedValue( mockShareConfig, ); mockLevelRepository.findById.mockResolvedValue(null); await expect( service.reportLevelProgress('user-uuid-2', reportDto), ).rejects.toThrow(NotFoundException); }); it('should update existing progress when not yet passed', async () => { mockShareConfigRepository.findByShareCode.mockResolvedValue( mockShareConfig, ); mockLevelRepository.findById.mockResolvedValue(mockLevels[0]); mockShareParticipantRepository.findByShareConfigAndParticipant.mockResolvedValue( mockParticipant, ); const existingProgress = { id: 'progress-uuid-1', participantId: 'participant-uuid-1', levelId: 'level-1', passed: false, timeSpent: 15, completedAt: new Date('2026-01-01'), } as ShareLevelProgress; mockShareLevelProgressRepository.findByParticipantAndLevel.mockResolvedValue( existingProgress, ); mockShareLevelProgressRepository.save.mockResolvedValue({ ...existingProgress, passed: true, timeSpent: 30, }); const result = await service.reportLevelProgress( 'user-uuid-2', reportDto, ); expect(result.passed).toBe(true); expect(mockShareLevelProgressRepository.save).toHaveBeenCalled(); }); }); });