Files
gongxue-base/scripts/crm-worker-integration-test.js
2026-06-29 04:35:19 +08:00

231 lines
8.2 KiB
JavaScript

import assert from 'node:assert/strict';
import http from 'node:http';
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 studentUserId = '00000000-0000-0000-0000-000000000101';
const salesUserId = '00000000-0000-0000-0000-000000000104';
function getFreePort() {
return new Promise((resolve, reject) => {
const server = http.createServer();
server.listen(0, '127.0.0.1', () => {
const address = server.address();
server.close(() => resolve(address.port));
});
server.on('error', reject);
});
}
async function startWebhookServer() {
const port = await getFreePort();
const requests = [];
const server = http.createServer((req, res) => {
let raw = '';
req.on('data', chunk => {
raw += chunk.toString();
});
req.on('end', () => {
requests.push({
url: req.url,
headers: req.headers,
body: raw ? JSON.parse(raw) : {},
});
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ ok: true }));
});
});
await new Promise(resolve => server.listen(port, '127.0.0.1', resolve));
return {
url: `http://127.0.0.1:${port}/crm`,
requests,
close: () => new Promise(resolve => server.close(resolve)),
};
}
async function runWorkerOnce() {
const child = spawn(process.execPath, ['apps/worker/dist/apps/worker/src/index.js', '--once', '--job', 'crm'], {
cwd: process.cwd(),
env: {
...process.env,
DATABASE_URL: databaseUrl,
WORKER_CRM_ALLOW_INSECURE_LOCALHOST: 'true',
WORKER_CRM_REQUEST_TIMEOUT_MS: '5000',
},
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, /processed=\d+/, 'worker output should include processed count');
}
async function main() {
const webhook = await startWebhookServer();
const pool = new pg.Pool({ connectionString: databaseUrl });
try {
await pool.query('begin');
await pool.query(
`
delete from public.crm_webhook_log
where tenant_id = $1 and record_id = $2
`,
[tenantId, studentUserId],
);
await pool.query(
`
delete from public.crm_webhook_queue
where tenant_id = $1 and idempotency_key = 'lead:worker-integration'
`,
[tenantId],
);
await pool.query(
`
insert into app_private.tenant_secrets (tenant_id, secret_scope, secret_key, secret_value, provider, last_rotated_at)
values ($1, 'crm', 'webhook', 'crm-secret-worker-test', 'webhook', now())
on conflict (tenant_id, secret_scope, secret_key)
do update set secret_value = excluded.secret_value,
provider = excluded.provider,
last_rotated_at = now(),
updated_at = now()
`,
[tenantId],
);
await pool.query(
`
insert into public.crm_config (
tenant_id, enabled, url, secret_ref, form_name, exam_type, timeout_sec, delay_sec
)
values ($1, true, $2, 'app_private.tenant_secrets:crm:webhook', 'Worker集成测试客资', '专升本', 5, 0)
on conflict (tenant_id)
do update set enabled = excluded.enabled,
url = excluded.url,
secret_ref = excluded.secret_ref,
form_name = excluded.form_name,
exam_type = excluded.exam_type,
timeout_sec = excluded.timeout_sec,
delay_sec = excluded.delay_sec,
updated_at = now()
`,
[tenantId, webhook.url],
);
await pool.query(
`
insert into public.referral_leads (
tenant_id, student_user_id, referrer_user_id, ref_code, source, bind_type, status, metadata
)
values ($1, $2, $3, 'WORKER1'::citext, 'integration', 'manual', 'protected', '{"source":"crm-worker-test"}'::jsonb)
on conflict (tenant_id, student_user_id)
do update set referrer_user_id = excluded.referrer_user_id,
ref_code = excluded.ref_code,
source = excluded.source,
bind_type = excluded.bind_type,
status = excluded.status,
updated_at = now()
`,
[tenantId, studentUserId, salesUserId],
);
const lead = await pool.query(
`
select id, bound_at
from public.referral_leads
where tenant_id = $1 and student_user_id = $2
limit 1
`,
[tenantId, studentUserId],
);
await pool.query(
`
insert into public.crm_webhook_queue (
tenant_id, record_id, status, scheduled_at, lead_id, source,
payload, idempotency_key, target_url
)
values (
$1, $2, 'pending', now() - interval '1 second', $3, 'integration',
$4::jsonb, 'lead:worker-integration', $5
)
on conflict (tenant_id, idempotency_key) where idempotency_key is not null
do update set status = 'pending',
scheduled_at = excluded.scheduled_at,
next_attempt_at = null,
attempts = 0,
last_error = null,
payload = excluded.payload,
target_url = excluded.target_url,
updated_at = now()
`,
[
tenantId,
studentUserId,
lead.rows[0].id,
JSON.stringify({
provider: 'generic',
formName: 'Worker集成测试客资',
examType: '专升本',
source: 'integration',
leadId: lead.rows[0].id,
student: { id: studentUserId, name: '烟测学生', phone: '13800000000' },
referral: { refCode: 'WORKER1', referrerUserId: salesUserId, boundAt: lead.rows[0].bound_at },
}),
webhook.url,
],
);
await pool.query('commit');
await runWorkerOnce();
assert.equal(webhook.requests.length, 1, 'worker should send exactly one webhook request');
assert.equal(webhook.requests[0].body.event, 'lead.created', 'generic webhook should send lead.created event');
assert.equal(webhook.requests[0].body.data.student.phone, '13800000000', 'webhook body should include lead phone');
assert.ok(!JSON.stringify(webhook.requests[0]).includes('crm-secret-worker-test'), 'webhook request should not leak CRM secret for generic provider');
const queue = await pool.query(
`
select status, attempts, last_http_code, sent_at
from public.crm_webhook_queue
where tenant_id = $1 and idempotency_key = 'lead:worker-integration'
limit 1
`,
[tenantId],
);
assert.equal(queue.rows[0]?.status, 'sent', 'CRM queue item should be sent');
assert.equal(queue.rows[0]?.attempts, 1, 'CRM queue item should record one attempt');
assert.equal(queue.rows[0]?.last_http_code, 200, 'CRM queue item should record HTTP 200');
assert.ok(queue.rows[0]?.sent_at, 'CRM queue item should record sent_at');
const logs = await pool.query(
`
select outcome, http_code, request_payload, response_summary
from public.crm_webhook_log
where tenant_id = $1 and record_id = $2
order by created_at desc
limit 1
`,
[tenantId, studentUserId],
);
assert.equal(logs.rows[0]?.outcome, 'sent', 'CRM log should record sent outcome');
assert.equal(logs.rows[0]?.http_code, 200, 'CRM log should record HTTP 200');
assert.equal(logs.rows[0]?.request_payload?.provider, 'generic', 'CRM log should record provider');
assert.ok(!JSON.stringify(logs.rows[0]).includes('crm-secret-worker-test'), 'CRM log should not leak secret');
console.log('CRM worker integration test complete.');
} finally {
await pool.end();
await webhook.close();
}
}
main().catch(error => {
console.error(error);
process.exit(1);
});