fix: add status guards to prevent data loss
- create: auto-delete REJECTED→throw error; add tenantId filter - remove: forbid PUBLISHED bank deletion - removeItem: forbid PUBLISHED item deletion - generateQuestions: restrict to DRAFT status only - frontend: render MULTIPLE_CHOICE options/judgment/followupHints - frontend: add judgment and followupHints to QuestionBankItem type - add 12 service guard tests (109 total)
This commit is contained in:
@@ -1,6 +1,11 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common';
|
||||
import {
|
||||
GENERATE_QUESTIONS_SYSTEM_PROMPT,
|
||||
parseGeneratedQuestion,
|
||||
QuestionBankService,
|
||||
} from './question-bank.service';
|
||||
import {
|
||||
QuestionBankItem,
|
||||
@@ -9,8 +14,13 @@ import {
|
||||
QuestionDimension,
|
||||
QuestionBankItemStatus,
|
||||
} from '../entities/question-bank-item.entity';
|
||||
import { QuestionBank, QuestionBankStatus } from '../entities/question-bank.entity';
|
||||
import { ModelConfigService } from '../../model-config/model-config.service';
|
||||
|
||||
const BANK_ID = 'test-bank-id';
|
||||
const TEMPLATE_ID = 'test-template-id';
|
||||
const USER_ID = 'user-1';
|
||||
const TENANT_ID = 'default';
|
||||
|
||||
describe('GENERATE_QUESTIONS_SYSTEM_PROMPT', () => {
|
||||
it('should require both choice and open question types', () => {
|
||||
@@ -259,3 +269,161 @@ describe('parseGeneratedQuestion', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('QuestionBankService - status guards', () => {
|
||||
let service: QuestionBankService;
|
||||
let bankRepo: any;
|
||||
let itemRepo: any;
|
||||
|
||||
const mockRepository = () => ({
|
||||
findOne: jest.fn(),
|
||||
find: jest.fn(),
|
||||
save: jest.fn().mockImplementation((entity: any) => Promise.resolve(entity)),
|
||||
create: jest.fn((dto: any) => dto as any),
|
||||
remove: jest.fn().mockResolvedValue(undefined),
|
||||
createQueryBuilder: jest.fn().mockReturnValue({
|
||||
leftJoinAndSelect: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
skip: jest.fn().mockReturnThis(),
|
||||
take: jest.fn().mockReturnThis(),
|
||||
getManyAndCount: jest.fn().mockResolvedValue([[], 0]),
|
||||
getMany: jest.fn().mockResolvedValue([]),
|
||||
}),
|
||||
});
|
||||
|
||||
const mockModelConfig = () => ({
|
||||
findDefaultByType: jest.fn().mockResolvedValue({
|
||||
apiKey: 'sk-test',
|
||||
modelId: 'deepseek-chat',
|
||||
baseUrl: 'https://api.deepseek.com/v1',
|
||||
}),
|
||||
});
|
||||
|
||||
const makeBank = (overrides?: Partial<QuestionBank>): QuestionBank =>
|
||||
({
|
||||
id: 'bank-1',
|
||||
name: 'Test Bank',
|
||||
status: QuestionBankStatus.DRAFT,
|
||||
templateId: TEMPLATE_ID,
|
||||
tenantId: TENANT_ID,
|
||||
...overrides,
|
||||
}) as QuestionBank;
|
||||
|
||||
const makeItem = (overrides?: Partial<QuestionBankItem>): QuestionBankItem =>
|
||||
({
|
||||
id: 'item-1',
|
||||
bankId: BANK_ID,
|
||||
questionText: 'Question?',
|
||||
questionType: QuestionType.SHORT_ANSWER,
|
||||
keyPoints: ['kp1'],
|
||||
difficulty: QuestionDifficulty.STANDARD,
|
||||
dimension: QuestionDimension.PROMPT,
|
||||
status: QuestionBankItemStatus.PENDING_REVIEW,
|
||||
...overrides,
|
||||
}) as QuestionBankItem;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
QuestionBankService,
|
||||
{ provide: getRepositoryToken(QuestionBank), useFactory: mockRepository },
|
||||
{ provide: getRepositoryToken(QuestionBankItem), useFactory: mockRepository },
|
||||
{ provide: ModelConfigService, useFactory: mockModelConfig },
|
||||
{ provide: ConfigService, useFactory: () => ({}) },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<QuestionBankService>(QuestionBankService);
|
||||
bankRepo = module.get(getRepositoryToken(QuestionBank));
|
||||
itemRepo = module.get(getRepositoryToken(QuestionBankItem));
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
const createDto = { name: 'New Bank', templateId: TEMPLATE_ID };
|
||||
|
||||
it('create: should allow cross-tenant when DRAFT exists for another tenant', async () => {
|
||||
bankRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
const result = await service.create(createDto, USER_ID, TENANT_ID);
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
|
||||
it('create: DRAFT exists same tenant → BadRequestException', async () => {
|
||||
bankRepo.findOne.mockResolvedValue(makeBank({ status: QuestionBankStatus.DRAFT }));
|
||||
|
||||
await expect(service.create(createDto, USER_ID, TENANT_ID)).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('create: REJECTED exists same tenant → BadRequestException', async () => {
|
||||
bankRepo.findOne.mockResolvedValue(makeBank({ status: QuestionBankStatus.REJECTED }));
|
||||
|
||||
await expect(service.create(createDto, USER_ID, TENANT_ID)).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('create: PUBLISHED exists same tenant → BadRequestException', async () => {
|
||||
bankRepo.findOne.mockResolvedValue(makeBank({ status: QuestionBankStatus.PUBLISHED }));
|
||||
|
||||
await expect(service.create(createDto, USER_ID, TENANT_ID)).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('create: no existing bank → success', async () => {
|
||||
bankRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
const result = await service.create(createDto, USER_ID, TENANT_ID);
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove', () => {
|
||||
it('remove: DRAFT → success', async () => {
|
||||
bankRepo.findOne.mockResolvedValue(makeBank({ status: QuestionBankStatus.DRAFT }));
|
||||
|
||||
await expect(service.remove('bank-1')).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('remove: REJECTED → success', async () => {
|
||||
bankRepo.findOne.mockResolvedValue(makeBank({ status: QuestionBankStatus.REJECTED }));
|
||||
|
||||
await expect(service.remove('bank-1')).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('remove: PUBLISHED → ForbiddenException', async () => {
|
||||
bankRepo.findOne.mockResolvedValue(makeBank({ status: QuestionBankStatus.PUBLISHED }));
|
||||
|
||||
await expect(service.remove('bank-1')).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeItem', () => {
|
||||
it('removeItem: PENDING_REVIEW item → success', async () => {
|
||||
bankRepo.findOne.mockResolvedValue(makeBank());
|
||||
itemRepo.findOne.mockResolvedValue(makeItem({ status: QuestionBankItemStatus.PENDING_REVIEW }));
|
||||
|
||||
await expect(service.removeItem(BANK_ID, 'item-1')).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('removeItem: PUBLISHED item → ForbiddenException', async () => {
|
||||
bankRepo.findOne.mockResolvedValue(makeBank());
|
||||
itemRepo.findOne.mockResolvedValue(makeItem({ status: QuestionBankItemStatus.PUBLISHED }));
|
||||
|
||||
await expect(service.removeItem(BANK_ID, 'item-1')).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateQuestions', () => {
|
||||
it('generateQuestions: PUBLISHED bank → ForbiddenException', async () => {
|
||||
bankRepo.findOne.mockResolvedValue(makeBank({ status: QuestionBankStatus.PUBLISHED }));
|
||||
|
||||
await expect(service.generateQuestions('bank-1', 1, 'some content', TENANT_ID))
|
||||
.rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
|
||||
it('generateQuestions: PENDING_REVIEW bank → ForbiddenException', async () => {
|
||||
bankRepo.findOne.mockResolvedValue(makeBank({ status: QuestionBankStatus.PENDING_REVIEW }));
|
||||
|
||||
await expect(service.generateQuestions('bank-1', 1, 'some content', TENANT_ID))
|
||||
.rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -266,13 +266,11 @@ export class QuestionBankService {
|
||||
}
|
||||
if (createDto.templateId) {
|
||||
const existing = await this.bankRepository.findOne({
|
||||
where: { templateId: createDto.templateId },
|
||||
where: { templateId: createDto.templateId, tenantId: tenantId || undefined as any },
|
||||
});
|
||||
if (existing) {
|
||||
if (existing.status === QuestionBankStatus.DRAFT || existing.status === QuestionBankStatus.REJECTED) {
|
||||
await this.bankRepository.remove(existing);
|
||||
} else {
|
||||
throw new BadRequestException('该模板已关联有效题库,请编辑已有题库');
|
||||
if (existing.status === QuestionBankStatus.DRAFT || existing.status === QuestionBankStatus.REJECTED || existing.status === QuestionBankStatus.PUBLISHED) {
|
||||
throw new BadRequestException('该模板已关联题库,请编辑已有题库或删除后重建');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -349,6 +347,9 @@ export class QuestionBankService {
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
const bank = await this.findOne(id);
|
||||
if (bank.status === QuestionBankStatus.PUBLISHED) {
|
||||
throw new ForbiddenException('已发布的题库不可删除');
|
||||
}
|
||||
await this.bankRepository.remove(bank);
|
||||
}
|
||||
|
||||
@@ -441,6 +442,9 @@ export class QuestionBankService {
|
||||
if (!item) {
|
||||
throw new NotFoundException(`QuestionBankItem with ID "${itemId}" not found`);
|
||||
}
|
||||
if (item.status === QuestionBankItemStatus.PUBLISHED) {
|
||||
throw new ForbiddenException('已发布的题目不可删除');
|
||||
}
|
||||
await this.itemRepository.remove(item);
|
||||
}
|
||||
|
||||
@@ -452,6 +456,10 @@ export class QuestionBankService {
|
||||
): Promise<QuestionBankItem[]> {
|
||||
const bank = await this.findOne(bankId);
|
||||
|
||||
if (bank.status !== QuestionBankStatus.DRAFT) {
|
||||
throw new ForbiddenException('仅草稿状态的题库可生成题目');
|
||||
}
|
||||
|
||||
if (count <= 0 || count > 50) {
|
||||
throw new BadRequestException('生成数量必须在 1-50 之间');
|
||||
}
|
||||
|
||||
@@ -335,6 +335,33 @@ export default function QuestionBankDetailView() {
|
||||
<span className={`inline-flex items-center gap-1 px-2.5 py-1 text-[10px] font-black uppercase tracking-widest rounded-full border ${itemStat.bg} ${itemStat.text} ${itemStat.border}`}>{itemStat.icon}{itemStat.label}</span>
|
||||
</div>
|
||||
<p className="font-bold text-slate-900 leading-relaxed">{item.questionText}</p>
|
||||
{item.questionType === 'MULTIPLE_CHOICE' && item.options && item.options.length > 0 && (
|
||||
<div className="mt-3 space-y-1.5 pl-1 border-l-2 border-blue-200">
|
||||
{item.options.map((opt, i) => {
|
||||
const letter = String.fromCharCode(65 + i);
|
||||
const isCorrect = item.correctAnswer === letter;
|
||||
return (
|
||||
<div key={i} className={`flex items-center gap-2 px-3 py-2 rounded-xl text-sm ${isCorrect ? 'bg-emerald-50 border border-emerald-200' : 'bg-slate-50'}`}>
|
||||
<span className={`inline-flex items-center justify-center w-6 h-6 rounded-lg text-[10px] font-black shrink-0 ${isCorrect ? 'bg-emerald-500 text-white' : 'bg-slate-200 text-slate-500'}`}>{letter}</span>
|
||||
<span className={`font-medium ${isCorrect ? 'text-emerald-700' : 'text-slate-600'}`}>{opt}</span>
|
||||
{isCorrect && <Check size={14} className="text-emerald-500 shrink-0 ml-auto" />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{item.judgment && (
|
||||
<div className="mt-3 bg-blue-50/50 border border-blue-100 rounded-xl p-3">
|
||||
<span className="text-[10px] font-black text-blue-400 uppercase tracking-widest">{item.questionType === 'MULTIPLE_CHOICE' ? '解析' : '判定依据'}</span>
|
||||
<p className="text-xs text-slate-600 mt-1 leading-relaxed">{item.judgment}</p>
|
||||
</div>
|
||||
)}
|
||||
{item.questionType === 'SHORT_ANSWER' && item.followupHints && item.followupHints.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1.5 items-center">
|
||||
<span className="text-[10px] font-black text-purple-400 uppercase tracking-widest">追问方向</span>
|
||||
{item.followupHints.map((hint, i) => <span key={i} className="px-2.5 py-1 bg-purple-50 text-purple-600 text-[10px] font-medium rounded-lg border border-purple-100/50">#{i + 1} {hint}</span>)}
|
||||
</div>
|
||||
)}
|
||||
{item.keyPoints.length > 0 && (
|
||||
<div className="mt-3 flex flex-wrap gap-1.5 items-center">
|
||||
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest mr-1">{t('gradingPoints')}</span>
|
||||
|
||||
@@ -17,6 +17,8 @@ export interface QuestionBankItem {
|
||||
questionType: 'SHORT_ANSWER' | 'MULTIPLE_CHOICE' | 'TRUE_FALSE';
|
||||
options?: string[] | null;
|
||||
correctAnswer?: string | null;
|
||||
judgment?: string | null;
|
||||
followupHints?: string[] | null;
|
||||
keyPoints: string[];
|
||||
difficulty: 'STANDARD' | 'ADVANCED' | 'SPECIALIST';
|
||||
dimension: 'PROMPT' | 'LLM' | 'IDE' | 'DEV_PATTERN' | 'WORK_CAPABILITY';
|
||||
|
||||
Reference in New Issue
Block a user