Files
MemeMind-Server/src/modules/share/share.service.spec.ts
2026-04-08 16:02:19 +08:00

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();
});
});
});