import assert from 'node:assert/strict'; import crypto from 'node:crypto'; 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 studentUserId = '00000000-0000-0000-0000-000000000101'; const planId = '00000000-0000-0000-0000-000000000201'; const regionId = '00000000-0000-0000-0000-000000000301'; const ids = { paymentOrder: '10000000-0000-0000-0000-000000000701', payment: '10000000-0000-0000-0000-000000000702', refundOrder: '10000000-0000-0000-0000-000000000703', refundPayment: '10000000-0000-0000-0000-000000000704', refundRequest: '10000000-0000-0000-0000-000000000705', refundEntitlement: '10000000-0000-0000-0000-000000000706', }; const orderNos = { payment: 'CW-WX-PAY-001', refund: 'CW-WX-REFUND-001', }; const refundNo = 'RF-CW-WX-001'; const paymentFixture = (() => { const wechatMerchant = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }); return { wechatMerchantPrivateKey: wechatMerchant.privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(), wechatApiV3Key: '12345678901234567890123456789012', }; })(); 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 startFakeWechatPayServer() { const port = await getFreePort(); const baseUrl = `http://127.0.0.1:${port}`; const requests = []; const server = http.createServer((req, res) => { const url = new URL(req.url || '/', baseUrl); let raw = ''; req.on('data', chunk => { raw += chunk.toString(); }); req.on('end', () => { requests.push({ method: req.method, pathname: url.pathname, search: url.search, body: raw ? JSON.parse(raw) : {} }); if (req.method === 'GET' && url.pathname.startsWith('/v3/pay/transactions/out-trade-no/')) { const outTradeNo = decodeURIComponent(url.pathname.split('/').pop() || ''); res.writeHead(200, { 'content-type': 'application/json' }); res.end(JSON.stringify({ appid: 'wx-worker-appid', mchid: 'wx-worker-mchid', out_trade_no: outTradeNo, transaction_id: `wx-trade-${outTradeNo}`, trade_state: 'SUCCESS', success_time: '2026-06-29T08:00:00+08:00', amount: { total: outTradeNo === orderNos.refund ? 500 : 990, currency: 'CNY' }, })); return; } if (req.method === 'GET' && url.pathname.startsWith('/v3/refund/domestic/refunds/')) { const outRefundNo = decodeURIComponent(url.pathname.split('/').pop() || ''); res.writeHead(200, { 'content-type': 'application/json' }); res.end(JSON.stringify({ refund_id: `refund-${outRefundNo}`, out_refund_no: outRefundNo, status: 'SUCCESS', amount: { refund: 500, total: 500, currency: 'CNY' }, success_time: '2026-06-29T09:00:00+08:00', })); return; } res.writeHead(404, { 'content-type': 'application/json' }); res.end(JSON.stringify({ code: 'NOT_FOUND' })); }); }); await new Promise((resolve, reject) => { server.once('error', reject); server.listen(port, '127.0.0.1', resolve); }); return { paymentQueryEndpoint: `${baseUrl}/v3/pay/transactions/out-trade-no`, refundEndpoint: `${baseUrl}/v3/refund/domestic/refunds`, 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', 'commerce'], { cwd: process.cwd(), env: { ...process.env, DATABASE_URL: databaseUrl, WORKER_COMMERCE_BATCH_SIZE: '20', WORKER_COMMERCE_MIN_AGE_SECONDS: '0', WORKER_COMMERCE_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, /commerce batch processed=\d+/, 'worker output should include commerce summary'); assert.ok(!output.includes(paymentFixture.wechatApiV3Key), 'worker output must not leak WeChat API v3 key'); assert.ok(!output.includes('PRIVATE KEY'), 'worker output must not leak merchant private key'); return output; } async function cleanup(pool) { await pool.query( ` delete from public.payment_events where tenant_id = $1 and ( event_id like $2 or event_id like $3 ) `, [tenantId, `${tenantId}:payment-query:CW-WX-%`, `${tenantId}:refund-query:RF-CW-%`], ); await pool.query( ` delete from public.commerce_refund_events where tenant_id = $1 and refund_request_id = $2 `, [tenantId, ids.refundRequest], ); await pool.query( ` delete from public.commerce_refund_requests where tenant_id = $1 and id = $2 `, [tenantId, ids.refundRequest], ); await pool.query( ` delete from public.entitlements where tenant_id = $1 and ( id = $2 or source_id in ($3::uuid, $4::uuid) ) `, [tenantId, ids.refundEntitlement, ids.paymentOrder, ids.refundOrder], ); await pool.query( ` delete from public.payments where tenant_id = $1 and id in ($2::uuid, $3::uuid) `, [tenantId, ids.payment, ids.refundPayment], ); await pool.query( ` delete from public.orders where tenant_id = $1 and id in ($2::uuid, $3::uuid) `, [tenantId, ids.paymentOrder, ids.refundOrder], ); } async function seed(pool, fakeWechat) { await pool.query( ` insert into app_private.tenant_secrets ( tenant_id, secret_scope, secret_key, secret_json, provider, last_rotated_at ) values ($1, 'payment', 'wechat_pay', $2::jsonb, 'wechat_pay', now()) on conflict (tenant_id, secret_scope, secret_key) do update set secret_json = excluded.secret_json, provider = excluded.provider, last_rotated_at = now(), updated_at = now() `, [tenantId, JSON.stringify({ privateKey: paymentFixture.wechatMerchantPrivateKey, apiV3Key: paymentFixture.wechatApiV3Key, })], ); await pool.query( ` insert into public.tenant_payment_accounts ( tenant_id, provider, mode, display_name, status, config_public ) values ($1, 'wechat_pay', 'tenant_collect', 'Worker WeChat Pay', 'active', $2::jsonb) on conflict (tenant_id, provider) do update set mode = excluded.mode, display_name = excluded.display_name, status = excluded.status, config_public = excluded.config_public, updated_at = now() `, [tenantId, JSON.stringify({ appId: 'wx-worker-appid', merchantId: 'wx-worker-mchid', merchantSerialNo: 'wx-worker-serial', paymentQueryEndpoint: fakeWechat.paymentQueryEndpoint, refundEndpoint: fakeWechat.refundEndpoint, refundQueryEndpoint: fakeWechat.refundEndpoint, secretRef: 'app_private.tenant_secrets:payment:wechat_pay', })], ); await pool.query( ` insert into public.orders ( id, tenant_id, user_id, order_no, status, product_type, product_name, amount_cents, pay_method, pay_provider, plan_id, days, region_id, raw_payload, created_at, updated_at ) values ($1, $2, $3, $4, 'pending', 'svip', 'Worker 补偿月卡', 990, 'jsapi', 'wechat_pay', $5, 30, $6, '{"source":"commerce-worker-test"}'::jsonb, now() - interval '10 minutes', now() - interval '10 minutes'), ($7, $2, $3, $8, 'paid', 'svip', 'Worker 退款月卡', 500, 'jsapi', 'wechat_pay', $5, 30, $6, '{"source":"commerce-worker-test"}'::jsonb, now() - interval '10 minutes', now() - interval '10 minutes') `, [ids.paymentOrder, tenantId, studentUserId, orderNos.payment, planId, regionId, ids.refundOrder, orderNos.refund], ); await pool.query( ` insert into public.payments ( id, tenant_id, order_id, provider, method, status, amount_cents, provider_trade_no, paid_at, raw_payload, created_at, updated_at ) values ($1, $2, $3, 'wechat_pay', 'jsapi', 'pending', 990, null, null, '{"source":"commerce-worker-test"}'::jsonb, now() - interval '10 minutes', now() - interval '10 minutes'), ($4, $2, $5, 'wechat_pay', 'jsapi', 'paid', 500, $6, now() - interval '9 minutes', '{"source":"commerce-worker-test"}'::jsonb, now() - interval '10 minutes', now() - interval '10 minutes') `, [ids.payment, tenantId, ids.paymentOrder, ids.refundPayment, ids.refundOrder, `wx-trade-${orderNos.refund}`], ); await pool.query( ` insert into public.entitlements ( id, tenant_id, user_id, entitlement_type, scope_type, scope_id, source_type, source_id, starts_at, expires_at, status, metadata ) values ($1, $2, $3, 'svip', 'region', $4, 'order', $5, now() - interval '9 minutes', now() + interval '30 days', 'active', '{"source":"commerce-worker-test"}'::jsonb) `, [ids.refundEntitlement, tenantId, studentUserId, regionId, ids.refundOrder], ); await pool.query( ` insert into public.commerce_refund_requests ( id, tenant_id, order_id, payment_id, refund_no, provider, provider_refund_no, status, amount_cents, reason, entitlement_action, requested_by, reviewed_by, processed_by, requested_at, reviewed_at, processed_at, metadata ) values ( $1, $2, $3, $4, $5, 'wechat_pay', null, 'processing', 500, 'commerce worker integration refund', 'revoke_on_success', null, null, null, now() - interval '8 minutes', now() - interval '8 minutes', now() - interval '8 minutes', '{"source":"commerce-worker-test"}'::jsonb ) `, [ids.refundRequest, tenantId, ids.refundOrder, ids.refundPayment, refundNo], ); } async function main() { const fakeWechat = await startFakeWechatPayServer(); const pool = new pg.Pool({ connectionString: databaseUrl }); let seeded = false; try { await pool.query('begin'); await cleanup(pool); await seed(pool, fakeWechat); await pool.query('commit'); seeded = true; const output = await runWorkerOnce(); assert.match(output, /paid=1/, 'worker should reconcile one paid payment'); assert.match(output, /succeeded=1/, 'worker should reconcile one succeeded refund'); assert.ok( fakeWechat.requests.some(item => item.method === 'GET' && item.pathname.endsWith(`/${orderNos.payment}`)), 'worker should query WeChat payment by out_trade_no', ); assert.ok( fakeWechat.requests.some(item => item.method === 'GET' && item.pathname.endsWith(`/${refundNo}`)), 'worker should query WeChat refund by out_refund_no', ); const paymentOrder = await pool.query( ` select o.status, o.trade_no, p.status as payment_status, p.provider_trade_no, count(e.id)::int as entitlement_count from public.orders o join public.payments p on p.tenant_id = o.tenant_id and p.order_id = o.id left join public.entitlements e on e.tenant_id = o.tenant_id and e.source_type = 'order' and e.source_id = o.id where o.tenant_id = $1 and o.id = $2 group by o.status, o.trade_no, p.status, p.provider_trade_no `, [tenantId, ids.paymentOrder], ); assert.equal(paymentOrder.rows[0]?.status, 'paid', 'payment compensation should mark order paid'); assert.equal(paymentOrder.rows[0]?.payment_status, 'paid', 'payment compensation should mark payment paid'); assert.equal(paymentOrder.rows[0]?.trade_no, `wx-trade-${orderNos.payment}`, 'payment compensation should record trade number'); assert.equal(paymentOrder.rows[0]?.entitlement_count, 1, 'payment compensation should grant one order entitlement'); const refund = await pool.query( ` select rr.status, rr.provider_refund_no, o.status as order_status, o.refunded_amount_cents, p.status as payment_status, p.refunded_amount_cents as payment_refunded_amount_cents, e.status as entitlement_status from public.commerce_refund_requests rr join public.orders o on o.tenant_id = rr.tenant_id and o.id = rr.order_id join public.payments p on p.tenant_id = rr.tenant_id and p.id = rr.payment_id left join public.entitlements e on e.tenant_id = rr.tenant_id and e.source_type = 'order' and e.source_id = rr.order_id where rr.tenant_id = $1 and rr.id = $2 `, [tenantId, ids.refundRequest], ); assert.equal(refund.rows[0]?.status, 'succeeded', 'refund compensation should mark refund succeeded'); assert.equal(refund.rows[0]?.provider_refund_no, `refund-${refundNo}`, 'refund compensation should record provider refund number'); assert.equal(refund.rows[0]?.order_status, 'refunded', 'full refund compensation should mark order refunded'); assert.equal(refund.rows[0]?.payment_status, 'refunded', 'full refund compensation should mark payment refunded'); assert.equal(refund.rows[0]?.refunded_amount_cents, 500, 'full refund compensation should update order refunded amount'); assert.equal(refund.rows[0]?.payment_refunded_amount_cents, 500, 'full refund compensation should update payment refunded amount'); assert.equal(refund.rows[0]?.entitlement_status, 'revoked', 'full refund compensation should revoke order entitlement'); await runWorkerOnce(); const secondPass = await pool.query( ` select count(*)::int as entitlement_count from public.entitlements where tenant_id = $1 and source_type = 'order' and source_id = $2 `, [tenantId, ids.paymentOrder], ); assert.equal(secondPass.rows[0]?.entitlement_count, 1, 'second worker pass must not grant duplicate entitlement'); const events = await pool.query( ` select payload, error from public.payment_events where tenant_id = $1 and ( event_id = $2 or event_id = $3 ) order by created_at desc `, [ tenantId, `${tenantId}:payment-query:${orderNos.payment}`, `${tenantId}:refund-query:${refundNo}`, ], ); assert.ok(events.rows.length >= 2, 'worker should record payment events for reconciliation'); assert.ok(!JSON.stringify(events.rows).includes(paymentFixture.wechatApiV3Key), 'payment event payload must not leak API v3 key'); assert.ok(!JSON.stringify(events.rows).includes('PRIVATE KEY'), 'payment event payload must not leak private key'); console.log('Commerce worker integration test complete.'); } catch (error) { await pool.query('rollback').catch(() => {}); throw error; } finally { if (seeded) { await cleanup(pool).catch(() => {}); } await pool.end(); await fakeWechat.close(); } } main().catch(error => { console.error(error); process.exit(1); });