410 lines
14 KiB
TypeScript
410 lines
14 KiB
TypeScript
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>(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<ShareLevelProgress> = {
|
|
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<ShareLevelProgress> = {
|
|
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<ShareLevelProgress> = {
|
|
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();
|
|
});
|
|
});
|
|
});
|