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 MAIN_TENANT_ID = '00000000-0000-0000-0000-000000000001'; const PARTNER_TENANT_ID = '00000000-0000-0000-0000-000000000901'; const PARTNER_ADMIN_USER_ID = '00000000-0000-0000-0000-000000000907'; const ids = { sourceQuestionBank: '00000000-0000-0000-0000-000000000400', grant: '00000000-0000-0000-0000-000000000906', targetQuestionBank: '00000000-0000-0000-0000-00000000c901', targetEntry: '00000000-0000-0000-0000-00000000c902', targetCollection: '00000000-0000-0000-0000-00000000c903', adoption: '00000000-0000-0000-0000-00000000c904', }; function runWorkerOnce() { const child = spawn(process.execPath, ['apps/worker/dist/apps/worker/src/index.js', '--once', '--job', 'public-banks'], { cwd: process.cwd(), env: { ...process.env, DATABASE_URL: databaseUrl, WORKER_PUBLIC_BANK_SYNC_BATCH_SIZE: '5', WORKER_PUBLIC_BANK_SYNC_COPY_LIMIT: '2', WORKER_PUBLIC_BANK_SYNC_ID: 'public-bank-sync-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, /public-banks batch processed=\d+/, 'worker output should include public bank sync summary'); resolve(output); } catch (error) { reject(error); } }); }); } async function cleanup(pool) { await pool.query('delete from public.audit_logs where tenant_id = $1 and target_id = $2', [PARTNER_TENANT_ID, ids.adoption]); await pool.query('delete from public.tenant_question_bank_adoptions where tenant_id = $1 and source_question_bank_id = $2', [PARTNER_TENANT_ID, ids.sourceQuestionBank]); await pool.query( ` delete from public.question_collection_items where tenant_id = $1 and (collection_id = $2 or metadata->>'source' = 'public_question_bank_adoption') `, [PARTNER_TENANT_ID, ids.targetCollection], ); 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 'public:%' ) `, [PARTNER_TENANT_ID], ); await pool.query('delete from public.questions where tenant_id = $1 and legacy_id like $2', [PARTNER_TENANT_ID, 'public:%']); await pool.query('delete from public.question_collections where tenant_id = $1 and id = $2', [PARTNER_TENANT_ID, ids.targetCollection]); await pool.query('delete from public.content_entries where tenant_id = $1 and id = $2', [PARTNER_TENANT_ID, ids.targetEntry]); await pool.query('delete from public.question_banks where tenant_id = $1 and id = $2', [PARTNER_TENANT_ID, ids.targetQuestionBank]); } async function createPendingAdoption(pool) { await pool.query( ` insert into public.question_banks (id, tenant_id, name, source_scope, status, metadata) values ($1, $2, 'worker 自动同步目标题库', 'tenant', 'active', '{"source":"public_bank_worker_test"}'::jsonb) `, [ids.targetQuestionBank, PARTNER_TENANT_ID], ); await pool.query( ` insert into public.content_entries ( id, tenant_id, entry_key, name, entry_type, icon, route, description, visibility, access_rules, layout_config, sort_order, is_active, created_by, updated_by ) values ( $1, $2, 'worker-public-bank-sync', 'worker 自动同步题库入口', 'question_practice', 'book-open', '/practice', 'worker integration public bank sync', 'public', '{}'::jsonb, '{}'::jsonb, 100, true, $3, $3 ) `, [ids.targetEntry, PARTNER_TENANT_ID, PARTNER_ADMIN_USER_ID], ); await pool.query( ` insert into public.question_collections ( id, tenant_id, entry_id, question_bank_id, name, collection_type, source_type, filters, question_count, status, sort_order, metadata, created_by, updated_by ) values ( $1, $2, $3, $4, 'worker 自动同步题目集合', 'manual', 'manual_questions', '{}'::jsonb, 0, 'active', 1, '{"source":"public_question_bank_adoption"}'::jsonb, $5, $5 ) `, [ids.targetCollection, PARTNER_TENANT_ID, ids.targetEntry, ids.targetQuestionBank, PARTNER_ADMIN_USER_ID], ); await pool.query( ` insert into public.tenant_question_bank_adoptions ( id, tenant_id, source_question_bank_id, grant_id, target_question_bank_id, target_entry_id, target_collection_id, adoption_mode, status, sync_status, source_snapshot, copied_question_count, metadata, created_by, updated_by ) values ( $1, $2, $3, $4, $5, $6, $7, 'copied_snapshot', 'active', 'pending', '{}'::jsonb, 0, '{"source":"public_bank_worker_test"}'::jsonb, $8, $8 ) `, [ ids.adoption, PARTNER_TENANT_ID, ids.sourceQuestionBank, ids.grant, ids.targetQuestionBank, ids.targetEntry, ids.targetCollection, PARTNER_ADMIN_USER_ID, ], ); } async function main() { const pool = new pg.Pool({ connectionString: databaseUrl }); try { await cleanup(pool); await createPendingAdoption(pool); const output = await runWorkerOnce(); assert.match(output, /processed=1/, 'worker should claim the pending public bank adoption'); assert.match(output, /synced=1/, 'worker should sync the pending public bank adoption'); const adoption = await pool.query( ` select status, sync_status, copied_question_count, metadata, last_synced_at from public.tenant_question_bank_adoptions where tenant_id = $1 and id = $2 `, [PARTNER_TENANT_ID, ids.adoption], ); assert.equal(adoption.rows[0]?.status, 'active', 'worker should return adoption to active status'); assert.equal(adoption.rows[0]?.sync_status, 'synced', 'worker should mark adoption synced'); assert.equal(Number(adoption.rows[0]?.copied_question_count), 2, 'worker should respect configured copy limit'); assert.equal(adoption.rows[0]?.metadata?.lastSync?.triggeredBy, 'worker', 'metadata should record worker-triggered sync'); assert.ok(adoption.rows[0]?.last_synced_at, 'worker should record last_synced_at'); const copied = await pool.query( ` select q.id, q.legacy_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 like $2 order by q.created_at asc `, [PARTNER_TENANT_ID, `public:${MAIN_TENANT_ID}:%`], ); assert.equal(copied.rowCount, 2, 'worker should create copied tenant questions'); assert.ok(copied.rows.every(row => row.content), 'copied questions should have current version content'); const collectionItems = await pool.query( ` select count(*)::integer as count from public.question_collection_items where tenant_id = $1 and collection_id = $2 `, [PARTNER_TENANT_ID, ids.targetCollection], ); assert.equal(Number(collectionItems.rows[0]?.count), 2, 'worker should bind copied questions to target collection'); const audit = await pool.query( ` select action, details from public.audit_logs where tenant_id = $1 and target_id = $2 and target_type = 'tenant_question_bank_adoption' order by created_at desc limit 1 `, [PARTNER_TENANT_ID, ids.adoption], ); assert.equal(audit.rows[0]?.action, 'content.public_question_bank.synced', 'worker should write sync audit'); assert.equal(audit.rows[0]?.details?.triggeredBy, 'worker', 'worker audit should record trigger source'); const secondOutput = await runWorkerOnce(); assert.match(secondOutput, /processed=0/, 'worker should skip already synced public bank when source has not changed'); console.log('Public question bank sync worker integration test complete.'); } finally { await cleanup(pool).catch(() => {}); await pool.end(); } } main().catch(error => { console.error(error); process.exit(1); });