feat: add question export foundation

This commit is contained in:
Codex
2026-06-29 09:05:42 +08:00
parent 35cff06df7
commit f63f8491f6
11 changed files with 901 additions and 22 deletions

View File

@@ -2253,6 +2253,16 @@ async function testTenantContentAdmin() {
correctOptionIndices: [1],
answerText: '4',
explanation: '基础加法。',
subQuestions: [
{
type: 'choice',
content: '集成测试子题1 + 1 = ?',
options: ['1', '2'],
correctOptionIndices: [1],
answerText: '2',
explanation: '子题基础加法。',
},
],
status: 'published',
examMarkers: { schoolName: '集成测试学院', salesIntent: true },
},
@@ -2312,6 +2322,118 @@ async function testTenantContentAdmin() {
});
assert.ok(publicCreatedCollections.items?.some(item => item.id === collection.item.id), 'catalog should expose created collection');
const exportDenied = await request('/api/tenant-content/exports/questions', {
method: 'POST',
body: { scopeType: 'collection', scopeId: collection.item.id },
expectStatus: 403,
});
assert.equal(exportDenied.code, 'TENANT_CONTENT_EDITOR_REQUIRED', 'student should not export tenant question bank content');
const exportNoAnswers = await request('/api/tenant-content/exports/questions', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
scopeType: 'collection',
scopeId: collection.item.id,
format: 'json',
exportType: 'questions',
includeAnswers: false,
includeExplanations: false,
options: { title: '集成测试无答案题库导出' },
},
});
assert.equal(exportNoAnswers.job?.status, 'completed', 'question export should create a completed export job');
assert.equal(exportNoAnswers.export?.summary?.questionCount, 1, 'question export should include collection questions');
assert.ok(exportNoAnswers.export?.files?.[0]?.contentBase64, 'question export should return a downloadable JSON payload');
assert.equal(
Object.hasOwn(exportNoAnswers.export?.questions?.[0] || {}, 'answerText'),
false,
'question export should omit answers when includeAnswers is false',
);
assert.equal(
Object.hasOwn(exportNoAnswers.export?.questions?.[0] || {}, 'explanation'),
false,
'question export should omit explanations when includeExplanations is false',
);
assert.equal(
Object.hasOwn(exportNoAnswers.export?.questions?.[0]?.subQuestions?.[0] || {}, 'correctOptionIndices'),
false,
'question export should omit sub question answers when includeAnswers is false',
);
assert.equal(
Object.hasOwn(exportNoAnswers.export?.questions?.[0]?.subQuestions?.[0] || {}, 'answerText'),
false,
'question export should omit sub question answer text when includeAnswers is false',
);
assert.equal(
Object.hasOwn(exportNoAnswers.export?.questions?.[0]?.subQuestions?.[0] || {}, 'explanation'),
false,
'question export should omit sub question explanations when includeExplanations is false',
);
const decodedExport = JSON.parse(Buffer.from(exportNoAnswers.export.files[0].contentBase64, 'base64').toString('utf8'));
assert.equal(decodedExport._tikuExport, '3.0', 'question export JSON should use the new export contract');
assert.equal(decodedExport.questions?.[0]?.content, '集成测试题2 + 2 = ?', 'question export JSON should include question content');
const exportBadLimit = await request('/api/tenant-content/exports/questions', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
scopeType: 'collection',
scopeId: collection.item.id,
limit: 'not-a-number',
},
});
assert.equal(exportBadLimit.export?.summary?.questionCount, 1, 'question export should tolerate invalid limit input with a safe default');
const paperExport = await request('/api/tenant-content/exports/questions', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
scopeType: 'content_node',
scopeId: childNode.item.id,
format: 'paper_json',
exportType: 'paper',
includeAnswers: true,
includeExplanations: true,
options: {
title: '集成测试试卷版导出',
durationMinutes: 90,
watermarkText: '仅供内部测试',
},
},
});
assert.equal(paperExport.export?.renderHints?.pdfLayout, 'paper', 'paper export should expose paper render hints');
assert.ok(
paperExport.export?.sections?.some(section => section.questions?.some(item => item.id === question.item.id)),
'paper export should group the created question by section',
);
const exportedQuestionWithAnswer = paperExport.export?.questions?.find(item => item.id === question.item.id);
assert.equal(exportedQuestionWithAnswer?.answerText, '4', 'paper export should include answers when requested');
const partnerExportDenied = await request('/api/tenant-content/exports/questions', {
tenantId: PARTNER_TENANT_ID,
userId: PARTNER_TENANT_ADMIN_USER_ID,
method: 'POST',
body: { scopeType: 'collection', scopeId: collection.item.id },
expectStatus: 404,
});
assert.equal(partnerExportDenied.code, 'QUESTION_COLLECTION_NOT_FOUND', 'question export must not read another tenant collection');
const exportJobs = await request('/api/tenant-content/exports/jobs', {
userId: TENANT_ADMIN_USER_ID,
query: { scopeType: 'collection', scopeId: collection.item.id, limit: 10 },
});
assert.ok(
exportJobs.items?.some(item => item.id === exportNoAnswers.job.id && item.questionCount === 1),
'question export jobs should list completed export audit records',
);
const invalidExportJobsScope = await request('/api/tenant-content/exports/jobs', {
userId: TENANT_ADMIN_USER_ID,
query: { scopeType: 'not_supported' },
expectStatus: 400,
});
assert.equal(invalidExportJobsScope.code, 'INVALID_EXPORT_SCOPE', 'question export jobs should validate scope type filters');
const createdBlueprintSession = await request('/api/learning/practice-sessions', {
method: 'POST',
body: { userId: USER_ID, blueprintId: blueprint.item.id },