import assert from 'node:assert/strict'; import pg from 'pg'; import { spawn } from 'node:child_process'; const databaseUrl = process.env.DATABASE_URL || 'postgresql://postgres:postgres@127.0.0.1:54322/postgres'; const tenantId = '00000000-0000-0000-0000-000000000001'; const adminUserId = '00000000-0000-0000-0000-000000000102'; const ids = { region: '00000000-0000-0000-0000-000000000301', subject: '00000000-0000-0000-0000-000000000501', category: '00000000-0000-0000-0000-000000000601', contentEntry: '00000000-0000-0000-0000-000000000611', contentNodeSchoolTarget: '00000000-0000-0000-0000-000000000614', questionCollection: '00000000-0000-0000-0000-000000000615', }; function runWorkerOnce() { const child = spawn(process.execPath, ['apps/worker/dist/apps/worker/src/index.js', '--once', '--job', 'imports'], { cwd: process.cwd(), env: { ...process.env, DATABASE_URL: databaseUrl, WORKER_IMPORT_BATCH_SIZE: '5', WORKER_IMPORT_ID: 'imports-integration-test', }, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, }); let output = ''; child.stdout.on('data', chunk => { output += chunk.toString(); }); child.stderr.on('data', chunk => { output += chunk.toString(); }); return new Promise((resolve, reject) => { child.on('error', reject); child.on('exit', code => { try { assert.equal(code, 0, `worker should exit 0\n${output}`); assert.match(output, /imports batch processed=\d+/, 'worker output should include imports summary'); resolve(output); } catch (error) { reject(error); } }); }); } async function cleanup(pool) { await pool.query( ` delete from public.question_collection_items where tenant_id = $1 and question_id in ( select id from public.questions where tenant_id = $1 and ( legacy_id like 'worker-import-question-%' or legacy_id like 'integration-import-async-choice-%' ) ) `, [tenantId], ); await pool.query( ` delete from public.question_versions where tenant_id = $1 and question_id in ( select id from public.questions where tenant_id = $1 and ( legacy_id like 'worker-import-question-%' or legacy_id like 'integration-import-async-choice-%' ) ) `, [tenantId], ); await pool.query( ` delete from public.questions where tenant_id = $1 and ( legacy_id like 'worker-import-question-%' or legacy_id like 'integration-import-async-choice-%' ) `, [tenantId], ); await pool.query( ` delete from public.audit_logs where tenant_id = $1 and target_type = 'content_import_job' and details::text like '%worker-import%' `, [tenantId], ); await pool.query( ` delete from public.content_import_jobs where tenant_id = $1 and ( source_name like 'worker-import-%' or source_name = 'async-question-import.json' ) `, [tenantId], ); } async function createQueuedQuestionImport(pool) { const preview = await pool.query( ` insert into public.content_import_jobs ( tenant_id, created_by, import_type, source_format, status, source_name, source_hash, target_region_id, target_subject_id, target_category_id, target_content_node_id, target_collection_id, dry_run, total_count, valid_count, error_count, warning_count, summary, raw_payload, normalized_payload, execution_mode, queued_at, next_attempt_at, parser_metadata ) values ( $1, $2, 'questions', 'json', 'pending', 'worker-import-questions.json', 'worker-import-source-hash', $3::uuid, $4::uuid, $5::uuid, $6::uuid, $7::uuid, false, 1, 1, 0, 0, $8::jsonb, $9::jsonb, $10::jsonb, 'async', now(), now(), '{}'::jsonb ) returning id `, [ tenantId, adminUserId, ids.region, ids.subject, ids.category, ids.contentNodeSchoolTarget, ids.questionCollection, JSON.stringify({ target: { regionId: ids.region, subjectId: ids.subject, categoryId: ids.category, contentNodeId: ids.contentNodeSchoolTarget, collectionId: ids.questionCollection, }, importOptions: { allowPartial: false }, source: 'worker-import-integration', }), JSON.stringify([ { legacyId: 'worker-import-question-001', type: 'choice', content: '异步导入题:worker 应该复用哪套导入规则?', options: ['自己重写', '复用后端导入 executor', '前端直写数据库', '跳过校验'], correctOptionIndices: [1], explanation: 'worker 和 API 必须复用同一套后端导入规则。', difficulty: 2, tags: ['worker-import'], }, ]), JSON.stringify([ { legacyId: 'worker-import-question-001', type: 'choice', typeLabel: null, content: '异步导入题:worker 应该复用哪套导入规则?', options: ['自己重写', '复用后端导入 executor', '前端直写数据库', '跳过校验'], correctOptionIndex: 1, correctOptionIndices: [1], answerText: null, explanation: 'worker 和 API 必须复用同一套后端导入规则。', difficulty: 2, tags: ['worker-import'], mediaUrl: null, subQuestions: [], codeLang: null, codeTemplate: null, examMarkers: {}, sourceHash: 'worker-import-question-hash-001', }, ]), ], ); const jobId = preview.rows[0].id; await pool.query( ` insert into public.content_import_items ( tenant_id, job_id, row_no, external_id, status, target_type, source_payload, normalized_payload, content_hash, issues_count ) values ($1, $2, 1, 'worker-import-question-001', 'valid', 'question', $3::jsonb, $4::jsonb, 'worker-import-question-hash-001', 0) `, [ tenantId, jobId, JSON.stringify({ legacyId: 'worker-import-question-001', content: '异步导入题:worker 应该复用哪套导入规则?', }), JSON.stringify({ legacyId: 'worker-import-question-001', type: 'choice', typeLabel: null, content: '异步导入题:worker 应该复用哪套导入规则?', options: ['自己重写', '复用后端导入 executor', '前端直写数据库', '跳过校验'], correctOptionIndex: 1, correctOptionIndices: [1], answerText: null, explanation: 'worker 和 API 必须复用同一套后端导入规则。', difficulty: 2, tags: ['worker-import'], mediaUrl: null, subQuestions: [], codeLang: null, codeTemplate: null, examMarkers: {}, sourceHash: 'worker-import-question-hash-001', }), ], ); return jobId; } async function main() { const pool = new pg.Pool({ connectionString: databaseUrl }); try { await cleanup(pool); const jobId = await createQueuedQuestionImport(pool); const output = await runWorkerOnce(); assert.match(output, /completed=1/, 'worker should complete exactly the queued import job after cleanup'); const job = await pool.query( ` select status, execution_mode, inserted_count, updated_count, skipped_count, locked_at, locked_by, attempt_count, error_message from public.content_import_jobs where tenant_id = $1 and id = $2 `, [tenantId, jobId], ); assert.equal(job.rows[0]?.status, 'completed', 'queued import job should be completed'); assert.equal(job.rows[0]?.execution_mode, 'async', 'job should keep async execution mode'); assert.equal(Number(job.rows[0]?.inserted_count), 1, 'worker should insert one question'); assert.equal(job.rows[0]?.locked_at, null, 'completed job should release lock'); assert.equal(job.rows[0]?.locked_by, null, 'completed job should clear lock owner'); assert.equal(Number(job.rows[0]?.attempt_count), 1, 'worker should record one attempt'); assert.equal(job.rows[0]?.error_message, null, 'completed job should not retain error message'); const question = await pool.query( ` select q.id, v.content from public.questions q join public.question_versions v on v.id = q.current_version_id where q.tenant_id = $1 and q.legacy_id = 'worker-import-question-001' limit 1 `, [tenantId], ); assert.equal(question.rows[0]?.content, '异步导入题:worker 应该复用哪套导入规则?', 'worker should import question content'); const collectionItem = await pool.query( ` select 1 from public.question_collection_items where tenant_id = $1 and collection_id = $2 and question_id = $3 limit 1 `, [tenantId, ids.questionCollection, question.rows[0]?.id], ); assert.equal(collectionItem.rowCount, 1, 'worker should bind imported question to collection'); const audit = await pool.query( ` select action from public.audit_logs where tenant_id = $1 and target_type = 'content_import_job' and target_id = $2 order by created_at desc limit 1 `, [tenantId, jobId], ); assert.equal(audit.rows[0]?.action, 'content.import.questions.completed', 'worker import should write completion audit'); console.log('Import worker integration test complete.'); } finally { await cleanup(pool).catch(() => {}); await pool.end(); } } main().catch(error => { console.error(error); process.exit(1); });