forked from wangziqi/gongxue-base
feat: add public question bank sync worker
This commit is contained in:
223
scripts/public-bank-worker-integration-test.js
Normal file
223
scripts/public-bank-worker-integration-test.js
Normal file
@@ -0,0 +1,223 @@
|
||||
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);
|
||||
});
|
||||
Reference in New Issue
Block a user