forked from wangziqi/gongxue-base
feat: render question exports as assets
This commit is contained in:
249
scripts/export-worker-integration-test.js
Normal file
249
scripts/export-worker-integration-test.js
Normal file
@@ -0,0 +1,249 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import pg from 'pg';
|
||||
import { spawn } from 'node:child_process';
|
||||
import JSZip from 'jszip';
|
||||
|
||||
const databaseUrl = process.env.DATABASE_URL || 'postgresql://postgres:postgres@127.0.0.1:54322/postgres';
|
||||
const tenantId = '00000000-0000-0000-0000-000000000001';
|
||||
const tenantAdminUserId = '00000000-0000-0000-0000-000000000102';
|
||||
const collectionId = '00000000-0000-0000-0000-000000000615';
|
||||
const localStorageRoot = '.local-storage-test';
|
||||
|
||||
const ids = {
|
||||
pdfJob: '21000000-0000-0000-0000-000000000901',
|
||||
docxJob: '21000000-0000-0000-0000-000000000902',
|
||||
noAnswerDocxJob: '21000000-0000-0000-0000-000000000903',
|
||||
};
|
||||
|
||||
const jobIds = [ids.pdfJob, ids.docxJob, ids.noAnswerDocxJob];
|
||||
|
||||
async function runWorkerOnce() {
|
||||
const child = spawn(process.execPath, ['apps/worker/dist/apps/worker/src/index.js', '--once', '--job', 'exports'], {
|
||||
cwd: process.cwd(),
|
||||
env: {
|
||||
...process.env,
|
||||
DATABASE_URL: databaseUrl,
|
||||
WORKER_EXPORT_BATCH_SIZE: '20',
|
||||
WORKER_EXPORT_ID: 'export-worker-test',
|
||||
STORAGE_DEFAULT_PROVIDER: 'local_dev',
|
||||
STORAGE_DEFAULT_BUCKET: 'tenant-assets',
|
||||
EXPORT_LOCAL_STORAGE_ROOT: localStorageRoot,
|
||||
STORAGE_REQUIRE_TENANT_PREFIX: 'true',
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
});
|
||||
let output = '';
|
||||
child.stdout.on('data', chunk => {
|
||||
output += chunk.toString();
|
||||
});
|
||||
child.stderr.on('data', chunk => {
|
||||
output += chunk.toString();
|
||||
});
|
||||
const code = await new Promise(resolve => child.on('exit', resolve));
|
||||
assert.equal(code, 0, `worker should exit 0\n${output}`);
|
||||
assert.match(output, /exports batch processed=\d+/, 'worker output should include exports summary');
|
||||
return output;
|
||||
}
|
||||
|
||||
async function cleanup(pool) {
|
||||
await pool.query(
|
||||
`
|
||||
delete from public.audit_logs
|
||||
where tenant_id = $1
|
||||
and (
|
||||
(target_type = 'content_export_job' and target_id = any($2::text[]))
|
||||
or (target_type = 'content_asset' and details->>'exportJobId' = any($2::text[]))
|
||||
)
|
||||
`,
|
||||
[tenantId, jobIds],
|
||||
);
|
||||
await pool.query(
|
||||
`
|
||||
delete from public.content_assets
|
||||
where tenant_id = $1
|
||||
and metadata->>'exportJobId' = any($2::text[])
|
||||
`,
|
||||
[tenantId, jobIds],
|
||||
);
|
||||
await pool.query(
|
||||
`
|
||||
delete from public.content_export_jobs
|
||||
where tenant_id = $1 and id = any($2::uuid[])
|
||||
`,
|
||||
[tenantId, jobIds],
|
||||
);
|
||||
await fs.rm(path.resolve(process.cwd(), localStorageRoot), { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function seed(pool) {
|
||||
await pool.query(
|
||||
`
|
||||
insert into public.content_export_jobs (
|
||||
id, tenant_id, created_by, export_type, format, scope_type, scope_id,
|
||||
status, include_answers, include_explanations, include_video_refs,
|
||||
question_count, summary, options, output_metadata, created_at, updated_at
|
||||
)
|
||||
values
|
||||
(
|
||||
$1, $4, $5, 'paper', 'pdf', 'collection', $6,
|
||||
'pending', false, false, false,
|
||||
0, '{"source":"export-worker-test"}'::jsonb,
|
||||
'{"title":"导出测试无答案试卷","watermarkText":"内部水印","publishToAssets":true,"assetVisibility":"tenant","limit":50}'::jsonb,
|
||||
'{"delivery":"content_asset_pending","maxQuestionLimit":50}'::jsonb,
|
||||
now() - interval '1 minute', now() - interval '1 minute'
|
||||
),
|
||||
(
|
||||
$2, $4, $5, 'paper', 'docx', 'collection', $6,
|
||||
'pending', true, true, false,
|
||||
0, '{"source":"export-worker-test"}'::jsonb,
|
||||
'{"title":"导出测试含答案文档","watermarkText":"内部水印","publishToAssets":false,"assetVisibility":"private","limit":50}'::jsonb,
|
||||
'{"delivery":"content_asset_pending","maxQuestionLimit":50}'::jsonb,
|
||||
now() - interval '1 minute', now() - interval '1 minute'
|
||||
),
|
||||
(
|
||||
$3, $4, $5, 'paper', 'docx', 'collection', $6,
|
||||
'pending', false, false, false,
|
||||
0, '{"source":"export-worker-test"}'::jsonb,
|
||||
'{"title":"导出测试无答案文档","watermarkText":"内部水印","publishToAssets":false,"assetVisibility":"private","limit":50}'::jsonb,
|
||||
'{"delivery":"content_asset_pending","maxQuestionLimit":50}'::jsonb,
|
||||
now() - interval '1 minute', now() - interval '1 minute'
|
||||
)
|
||||
`,
|
||||
[ids.pdfJob, ids.docxJob, ids.noAnswerDocxJob, tenantId, tenantAdminUserId, collectionId],
|
||||
);
|
||||
}
|
||||
|
||||
function countFromWorkerOutput(output, name) {
|
||||
const match = output.match(new RegExp(`${name}=(\\d+)`));
|
||||
return match ? Number(match[1]) : 0;
|
||||
}
|
||||
|
||||
async function generatedFileBody(asset) {
|
||||
const fullPath = path.resolve(process.cwd(), localStorageRoot, asset.bucket, asset.object_key);
|
||||
return fs.readFile(fullPath);
|
||||
}
|
||||
|
||||
async function assertGeneratedFile(asset) {
|
||||
const body = await generatedFileBody(asset);
|
||||
assert.equal(body.length, Number(asset.file_size_bytes), 'generated file size should match content_assets');
|
||||
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 {
|
||||
assert.equal(body.subarray(0, 2).toString('hex'), '504b', 'DOCX export should be a zipped Office file');
|
||||
}
|
||||
}
|
||||
|
||||
async function docxText(asset) {
|
||||
const zip = await JSZip.loadAsync(await generatedFileBody(asset));
|
||||
const xml = await zip.file('word/document.xml')?.async('string');
|
||||
assert.ok(xml, 'DOCX should contain word/document.xml');
|
||||
return xml
|
||||
.replace(/<[^>]+>/g, '')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/&/g, '&');
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const pool = new pg.Pool({ connectionString: databaseUrl });
|
||||
let seeded = false;
|
||||
try {
|
||||
await pool.query('begin');
|
||||
await cleanup(pool);
|
||||
await seed(pool);
|
||||
await pool.query('commit');
|
||||
seeded = true;
|
||||
|
||||
const output = await runWorkerOnce();
|
||||
assert.ok(countFromWorkerOutput(output, 'completed') >= 3, 'worker should complete all binary export jobs');
|
||||
|
||||
const jobs = await pool.query(
|
||||
`
|
||||
select id, status, asset_id, question_count, output_metadata
|
||||
from public.content_export_jobs
|
||||
where tenant_id = $1 and id = any($2::uuid[])
|
||||
order by id
|
||||
`,
|
||||
[tenantId, jobIds],
|
||||
);
|
||||
assert.equal(jobs.rowCount, 3, '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');
|
||||
assert.equal(job.output_metadata?.delivery, 'content_asset', 'export job should switch delivery to content_asset');
|
||||
assert.ok(job.output_metadata?.checksumSha256, 'export job should record output checksum');
|
||||
assert.ok(Number(job.question_count) >= 1, 'export job should record question count');
|
||||
}
|
||||
|
||||
const assets = await pool.query(
|
||||
`
|
||||
select id, asset_type, title, file_name, storage_provider, bucket,
|
||||
object_key, mime_type, file_size_bytes, checksum_sha256,
|
||||
upload_status, visibility, status, metadata
|
||||
from public.content_assets
|
||||
where tenant_id = $1 and metadata->>'exportJobId' = any($2::text[])
|
||||
order by file_name
|
||||
`,
|
||||
[tenantId, jobIds],
|
||||
);
|
||||
assert.equal(assets.rowCount, 3, 'worker should create three 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);
|
||||
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.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(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(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');
|
||||
await assertGeneratedFile(pdfAsset);
|
||||
await assertGeneratedFile(docxAsset);
|
||||
await assertGeneratedFile(noAnswerDocxAsset);
|
||||
|
||||
const answeredDocxText = await docxText(docxAsset);
|
||||
assert.match(answeredDocxText, /答案/, 'DOCX with answers should include answer labels');
|
||||
assert.match(answeredDocxText, /后端需要统一校验租户/, 'DOCX with answers should include subjective answer text');
|
||||
const noAnswerDocxText = await docxText(noAnswerDocxAsset);
|
||||
assert.doesNotMatch(noAnswerDocxText, /后端需要统一校验租户/, 'DOCX without answers should not leak subjective answer text');
|
||||
assert.doesNotMatch(noAnswerDocxText, /基础加法。/, 'DOCX without explanations should not leak explanations');
|
||||
|
||||
const audits = await pool.query(
|
||||
`
|
||||
select action, details
|
||||
from public.audit_logs
|
||||
where tenant_id = $1
|
||||
and target_type = 'content_export_job'
|
||||
and target_id = any($2::text[])
|
||||
`,
|
||||
[tenantId, jobIds],
|
||||
);
|
||||
assert.ok(
|
||||
audits.rows.some(row => row.action === 'content.questions.export_rendered' && row.details?.assetId),
|
||||
'worker should write rendered audit log',
|
||||
);
|
||||
|
||||
console.log('Export worker integration test complete.');
|
||||
} catch (error) {
|
||||
await pool.query('rollback').catch(() => {});
|
||||
throw error;
|
||||
} finally {
|
||||
if (seeded) {
|
||||
await cleanup(pool).catch(() => {});
|
||||
}
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(error => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user