Files
gongxue-base/scripts/import-worker-integration-test.js
2026-06-29 08:00:17 +08:00

300 lines
9.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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);
});