forked from wangziqi/gongxue-base
feat: export daily practice image packages
This commit is contained in:
@@ -2849,6 +2849,47 @@ async function testTenantContentAdmin() {
|
||||
'daily practice export should cap question count for nine-grid material',
|
||||
);
|
||||
|
||||
const invalidDailyPracticeZip = await request('/api/tenant-content/exports/questions', {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
method: 'POST',
|
||||
body: {
|
||||
scopeType: 'collection',
|
||||
scopeId: collection.item.id,
|
||||
format: 'daily_practice_zip',
|
||||
exportType: 'paper',
|
||||
},
|
||||
expectStatus: 400,
|
||||
});
|
||||
assert.equal(
|
||||
invalidDailyPracticeZip.code,
|
||||
'DAILY_PRACTICE_ZIP_REQUIRES_DAILY_PRACTICE',
|
||||
'daily practice ZIP export should reject non-daily-practice export types',
|
||||
);
|
||||
|
||||
const dailyPracticeZipJob = await request('/api/tenant-content/exports/questions', {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
method: 'POST',
|
||||
body: {
|
||||
scopeType: 'collection',
|
||||
scopeId: collection.item.id,
|
||||
format: 'daily_practice_zip',
|
||||
exportType: 'daily_practice',
|
||||
includeAnswers: false,
|
||||
includeExplanations: false,
|
||||
limit: 99,
|
||||
options: {
|
||||
title: '集成测试每日一练图片包',
|
||||
issue: '每日一练 图片包',
|
||||
date: '2026-06-29',
|
||||
theme: 'default',
|
||||
publishToAssets: true,
|
||||
assetVisibility: 'tenant',
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.equal(dailyPracticeZipJob.job?.status, 'pending', 'daily practice ZIP export should create a pending async job');
|
||||
assert.equal(dailyPracticeZipJob.export, null, 'daily practice ZIP export should not return inline ZIP payload');
|
||||
|
||||
const pdfExportJob = await request('/api/tenant-content/exports/questions', {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
method: 'POST',
|
||||
@@ -2909,6 +2950,9 @@ async function testTenantContentAdmin() {
|
||||
assert.equal(listedPdfJob?.status, 'pending', 'question export jobs should expose pending binary export jobs');
|
||||
assert.equal(listedPdfJob?.assetId, null, 'pending binary export job should not have an asset yet');
|
||||
assert.equal(listedPdfJob?.attemptCount, 0, 'pending binary export job should expose worker attempt count');
|
||||
const listedDailyPracticeZipJob = exportJobs.items?.find(item => item.id === dailyPracticeZipJob.job.id);
|
||||
assert.equal(listedDailyPracticeZipJob?.format, 'daily_practice_zip', 'export jobs should list pending daily practice ZIP jobs');
|
||||
assert.equal(listedDailyPracticeZipJob?.status, 'pending', 'pending daily practice ZIP job should be visible to tenant content admins');
|
||||
const invalidExportJobsScope = await request('/api/tenant-content/exports/jobs', {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
query: { scopeType: 'not_supported' },
|
||||
|
||||
@@ -16,9 +16,10 @@ const ids = {
|
||||
docxJob: '21000000-0000-0000-0000-000000000902',
|
||||
noAnswerDocxJob: '21000000-0000-0000-0000-000000000903',
|
||||
dailyPdfJob: '21000000-0000-0000-0000-000000000904',
|
||||
dailyZipJob: '21000000-0000-0000-0000-000000000905',
|
||||
};
|
||||
|
||||
const jobIds = [ids.pdfJob, ids.docxJob, ids.noAnswerDocxJob, ids.dailyPdfJob];
|
||||
const jobIds = [ids.pdfJob, ids.docxJob, ids.noAnswerDocxJob, ids.dailyPdfJob, ids.dailyZipJob];
|
||||
|
||||
async function runWorkerOnce() {
|
||||
const child = spawn(process.execPath, ['apps/worker/dist/apps/worker/src/index.js', '--once', '--job', 'exports'], {
|
||||
@@ -119,9 +120,17 @@ async function seed(pool) {
|
||||
'{"title":"每日一练测试","issue":"每日一练 第 1 期","date":"2026-06-29","watermarkText":"每日一练水印","publishToAssets":true,"assetVisibility":"tenant","limit":99,"brand":{"name":"恭学教育","english":"GONGXUE EDU","slogan":"专注高职升本","ctaLine":"每日一练 · 稳步上岸"}}'::jsonb,
|
||||
'{"delivery":"content_asset_pending","maxQuestionLimit":8}'::jsonb,
|
||||
now() - interval '1 minute', now() - interval '1 minute'
|
||||
),
|
||||
(
|
||||
$8, $4, $5, 'daily_practice', 'daily_practice_zip', 'collection', $6,
|
||||
'pending', false, false, false,
|
||||
0, '{"source":"export-worker-test"}'::jsonb,
|
||||
'{"title":"每日一练图片包测试","issue":"每日一练 第 2 期","date":"2026-06-29","theme":"ink","cardFormat":"1:1","watermarkText":"每日一练水印","publishToAssets":true,"assetVisibility":"tenant","limit":99,"brand":{"name":"恭学教育","english":"GONGXUE EDU","slogan":"专注高职升本","ctaLine":"每日一练 · 稳步上岸"}}'::jsonb,
|
||||
'{"delivery":"content_asset_pending","maxQuestionLimit":8}'::jsonb,
|
||||
now() - interval '1 minute', now() - interval '1 minute'
|
||||
)
|
||||
`,
|
||||
[ids.pdfJob, ids.docxJob, ids.noAnswerDocxJob, tenantId, tenantAdminUserId, collectionId, ids.dailyPdfJob],
|
||||
[ids.pdfJob, ids.docxJob, ids.noAnswerDocxJob, tenantId, tenantAdminUserId, collectionId, ids.dailyPdfJob, ids.dailyZipJob],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -141,6 +150,8 @@ async function assertGeneratedFile(asset) {
|
||||
assert.ok(body.length > 500, 'generated binary export should not be empty');
|
||||
if (asset.mime_type === 'application/pdf') {
|
||||
assert.equal(body.subarray(0, 4).toString('utf8'), '%PDF', 'PDF export should have PDF header');
|
||||
} else if (asset.mime_type === 'application/zip') {
|
||||
assert.equal(body.subarray(0, 2).toString('hex'), '504b', 'ZIP export should have ZIP header');
|
||||
} else {
|
||||
assert.equal(body.subarray(0, 2).toString('hex'), '504b', 'DOCX export should be a zipped Office file');
|
||||
}
|
||||
@@ -157,6 +168,19 @@ async function docxText(asset) {
|
||||
.replace(/&/g, '&');
|
||||
}
|
||||
|
||||
async function zipEntries(asset) {
|
||||
const zip = await JSZip.loadAsync(await generatedFileBody(asset));
|
||||
const manifestText = await zip.file('manifest.json')?.async('string');
|
||||
const payloadText = await zip.file('payload.json')?.async('string');
|
||||
assert.ok(manifestText, 'daily practice ZIP should contain manifest.json');
|
||||
assert.ok(payloadText, 'daily practice ZIP should contain payload.json');
|
||||
return {
|
||||
manifest: JSON.parse(manifestText),
|
||||
payload: JSON.parse(payloadText),
|
||||
names: Object.keys(zip.files).filter(name => !zip.files[name].dir),
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const pool = new pg.Pool({ connectionString: databaseUrl });
|
||||
let seeded = false;
|
||||
@@ -168,7 +192,7 @@ async function main() {
|
||||
seeded = true;
|
||||
|
||||
const output = await runWorkerOnce();
|
||||
assert.ok(countFromWorkerOutput(output, 'completed') >= 4, 'worker should complete all binary export jobs');
|
||||
assert.ok(countFromWorkerOutput(output, 'completed') >= 5, 'worker should complete all binary export jobs');
|
||||
|
||||
const jobs = await pool.query(
|
||||
`
|
||||
@@ -179,7 +203,7 @@ async function main() {
|
||||
`,
|
||||
[tenantId, jobIds],
|
||||
);
|
||||
assert.equal(jobs.rowCount, 4, 'all export jobs should still exist');
|
||||
assert.equal(jobs.rowCount, 5, 'all export jobs should still exist');
|
||||
for (const job of jobs.rows) {
|
||||
assert.equal(job.status, 'completed', 'export job should be completed');
|
||||
assert.ok(job.asset_id, 'completed export job should reference a content asset');
|
||||
@@ -199,30 +223,38 @@ async function main() {
|
||||
`,
|
||||
[tenantId, jobIds],
|
||||
);
|
||||
assert.equal(assets.rowCount, 4, 'worker should create four export assets');
|
||||
assert.equal(assets.rowCount, 5, 'worker should create five export assets');
|
||||
const pdfAsset = assets.rows.find(asset => asset.metadata?.exportJobId === ids.pdfJob);
|
||||
const docxAsset = assets.rows.find(asset => asset.metadata?.exportJobId === ids.docxJob);
|
||||
const noAnswerDocxAsset = assets.rows.find(asset => asset.metadata?.exportJobId === ids.noAnswerDocxJob);
|
||||
const dailyPdfAsset = assets.rows.find(asset => asset.metadata?.exportJobId === ids.dailyPdfJob);
|
||||
const dailyZipAsset = assets.rows.find(asset => asset.metadata?.exportJobId === ids.dailyZipJob);
|
||||
assert.ok(pdfAsset, 'PDF export should create a PDF content asset');
|
||||
assert.ok(docxAsset, 'DOCX export should create a Word content asset');
|
||||
assert.ok(noAnswerDocxAsset, 'no-answer DOCX export should create a Word content asset');
|
||||
assert.ok(dailyPdfAsset, 'daily practice PDF export should create a PDF content asset');
|
||||
assert.ok(dailyZipAsset, 'daily practice ZIP export should create a package content asset');
|
||||
assert.equal(pdfAsset.mime_type, 'application/pdf', 'PDF asset should use application/pdf');
|
||||
assert.ok(docxAsset.mime_type?.includes('wordprocessingml'), 'DOCX asset should use Word MIME type');
|
||||
assert.equal(dailyZipAsset.mime_type, 'application/zip', 'daily practice package should use application/zip');
|
||||
assert.equal(dailyZipAsset.asset_type, 'package', 'daily practice ZIP should use package asset type');
|
||||
assert.equal(pdfAsset.upload_status, 'verified', 'PDF asset should be verified');
|
||||
assert.equal(docxAsset.upload_status, 'verified', 'DOCX asset should be verified');
|
||||
assert.equal(noAnswerDocxAsset.upload_status, 'verified', 'no-answer DOCX asset should be verified');
|
||||
assert.equal(dailyPdfAsset.upload_status, 'verified', 'daily practice PDF asset should be verified');
|
||||
assert.equal(dailyZipAsset.upload_status, 'verified', 'daily practice ZIP asset should be verified');
|
||||
assert.equal(pdfAsset.visibility, 'tenant', 'published PDF export should use requested asset visibility');
|
||||
assert.equal(docxAsset.visibility, 'private', 'non-published DOCX export should remain private');
|
||||
assert.equal(noAnswerDocxAsset.visibility, 'private', 'no-answer DOCX export should remain private');
|
||||
assert.equal(dailyPdfAsset.visibility, 'tenant', 'daily practice PDF export should use requested asset visibility');
|
||||
assert.equal(dailyZipAsset.visibility, 'tenant', 'daily practice ZIP export should use requested asset visibility');
|
||||
assert.equal(dailyPdfAsset.metadata?.exportType, 'daily_practice', 'daily practice asset should keep export type metadata');
|
||||
assert.equal(dailyZipAsset.metadata?.format, 'daily_practice_zip', 'daily practice package should keep export format metadata');
|
||||
await assertGeneratedFile(pdfAsset);
|
||||
await assertGeneratedFile(docxAsset);
|
||||
await assertGeneratedFile(noAnswerDocxAsset);
|
||||
await assertGeneratedFile(dailyPdfAsset);
|
||||
await assertGeneratedFile(dailyZipAsset);
|
||||
|
||||
const answeredDocxText = await docxText(docxAsset);
|
||||
assert.match(answeredDocxText, /答案/, 'DOCX with answers should include answer labels');
|
||||
@@ -231,6 +263,26 @@ async function main() {
|
||||
assert.doesNotMatch(noAnswerDocxText, /后端需要统一校验租户/, 'DOCX without answers should not leak subjective answer text');
|
||||
assert.doesNotMatch(noAnswerDocxText, /基础加法。/, 'DOCX without explanations should not leak explanations');
|
||||
|
||||
const dailyZip = await zipEntries(dailyZipAsset);
|
||||
assert.equal(dailyZip.manifest.exportType, 'daily_practice', 'daily practice package manifest should keep export type');
|
||||
assert.equal(dailyZip.manifest.format, 'daily_practice_zip', 'daily practice package manifest should keep format');
|
||||
assert.equal(dailyZip.manifest.safety?.includeAnswers, false, 'daily practice package should record answer redaction');
|
||||
assert.ok(dailyZip.names.includes('collage.png'), 'daily practice package should include collage PNG');
|
||||
assert.ok(dailyZip.names.includes('collage.svg'), 'daily practice package should include collage SVG source');
|
||||
assert.equal(dailyZip.names.filter(name => /^cards\/card-\d{2}\.png$/.test(name)).length, 9, 'daily practice package should include nine PNG cards');
|
||||
assert.equal(dailyZip.names.filter(name => /^cards\/card-\d{2}\.svg$/.test(name)).length, 9, 'daily practice package should include nine SVG card sources');
|
||||
assert.equal(dailyZip.payload.questions?.[0]?.answerText, undefined, 'daily practice package payload should omit answer text when disabled');
|
||||
assert.equal(dailyZip.payload.questions?.[0]?.explanation, undefined, 'daily practice package payload should omit explanations when disabled');
|
||||
assert.ok(
|
||||
dailyZip.payload.summary?.cardCount >= 1 && dailyZip.payload.summary?.cardCount <= 8,
|
||||
'daily practice package payload should cap card count to the nine-grid question slots',
|
||||
);
|
||||
assert.equal(
|
||||
dailyZip.payload.summary?.cardCount,
|
||||
dailyZip.manifest.summary?.cardCount,
|
||||
'daily practice package manifest and payload should agree on card count',
|
||||
);
|
||||
|
||||
const audits = await pool.query(
|
||||
`
|
||||
select action, details
|
||||
|
||||
Reference in New Issue
Block a user