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', dailyPdfJob: '21000000-0000-0000-0000-000000000904', dailyZipJob: '21000000-0000-0000-0000-000000000905', }; 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'], { 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' ), ( $7, $4, $5, 'daily_practice', 'pdf', 'collection', $6, 'pending', true, false, false, 0, '{"source":"export-worker-test"}'::jsonb, '{"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.dailyZipJob], ); } 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 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'); } } 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 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; 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') >= 5, '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, 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'); 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, 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'); 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 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 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); });