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 ids = { tenant: '00000000-0000-0000-0000-00000000d901', invoice: '00000000-0000-0000-0000-00000000d902', }; function runWorkerOnce() { const child = spawn(process.execPath, ['apps/worker/dist/apps/worker/src/index.js', '--once', '--job', 'platform-dunning'], { cwd: process.cwd(), env: { ...process.env, DATABASE_URL: databaseUrl, WORKER_PLATFORM_DUNNING_BATCH_SIZE: '10', WORKER_PLATFORM_DUNNING_ID: 'platform-dunning-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, /platform-dunning batch processed=\d+/, 'worker output should include platform dunning summary'); resolve(output); } catch (error) { reject(error); } }); }); } async function cleanup(pool) { await pool.query('delete from public.audit_logs where tenant_id = $1', [ids.tenant]); await pool.query('delete from public.tenant_invoice_reminders where tenant_id = $1', [ids.tenant]); await pool.query('delete from public.tenant_invoice_items where tenant_id = $1', [ids.tenant]); await pool.query('delete from public.tenant_invoice_payments where tenant_id = $1', [ids.tenant]); await pool.query('delete from public.tenant_invoices where tenant_id = $1', [ids.tenant]); await pool.query('delete from public.tenant_billing_profiles where tenant_id = $1', [ids.tenant]); await pool.query('delete from public.tenant_domains where tenant_id = $1', [ids.tenant]); await pool.query('delete from public.tenants where id = $1', [ids.tenant]); } async function createTenantAndInvoice(pool) { await pool.query( ` insert into public.tenants (id, slug, name, legal_name, status, mode, billing_status, metadata) values ($1, 'platform-dunning-worker', '平台催缴测试租户', '平台催缴测试有限公司', 'active', 'saas', 'active', '{"source":"platform-dunning-worker-test"}'::jsonb) `, [ids.tenant], ); await pool.query( ` insert into public.tenant_invoices ( id, tenant_id, invoice_no, invoice_type, status, currency, subtotal_cents, total_cents, paid_cents, balance_cents, due_date, issued_at, note, metadata ) values ( $1, $2, 'ODWORKER202606300001', 'service_fee', 'issued', 'CNY', 660000, 660000, 0, 660000, current_date - interval '5 days', now(), 'platform dunning worker invoice', '{"source":"platform-dunning-worker-test"}'::jsonb ) `, [ids.invoice, ids.tenant], ); } async function main() { const pool = new pg.Pool({ connectionString: databaseUrl }); try { await cleanup(pool); await createTenantAndInvoice(pool); const firstOutput = await runWorkerOnce(); assert.match(firstOutput, /processed=\d+/, 'worker should process overdue invoice candidates'); assert.match(firstOutput, /markedOverdue=\d+/, 'worker should mark overdue invoice candidates'); assert.match(firstOutput, /reminderCreated=\d+/, 'worker should create reminder candidates'); const invoice = await pool.query( ` select status, metadata from public.tenant_invoices where tenant_id = $1 and id = $2 `, [ids.tenant, ids.invoice], ); assert.equal(invoice.rows[0]?.status, 'overdue', 'worker should update invoice status to overdue'); assert.equal(invoice.rows[0]?.metadata?.overdueMarkedBy, 'platform-dunning-integration-test', 'worker should record overdue marker id'); const tenant = await pool.query('select billing_status from public.tenants where id = $1', [ids.tenant]); assert.equal(tenant.rows[0]?.billing_status, 'past_due', 'worker should mark tenant billing status past_due'); const reminders = await pool.query( ` select reminder_type, channel, status, reminder_level, balance_cents_snapshot, metadata from public.tenant_invoice_reminders where tenant_id = $1 and invoice_id = $2 `, [ids.tenant, ids.invoice], ); assert.equal(reminders.rowCount, 1, 'worker should create exactly one reminder for the day'); assert.equal(reminders.rows[0].reminder_type, 'overdue', 'worker reminder type should be overdue'); assert.equal(reminders.rows[0].channel, 'internal', 'worker reminder channel should be internal'); assert.equal(Number(reminders.rows[0].balance_cents_snapshot), 660000, 'worker reminder should snapshot balance'); assert.equal(reminders.rows[0].metadata?.workerId, 'platform-dunning-integration-test', 'worker reminder should record worker id'); const audit = await pool.query( ` select action, target_type, target_id, details from public.audit_logs where tenant_id = $1 and action = 'platform.invoice.overdue_processed' order by created_at desc limit 1 `, [ids.tenant], ); assert.equal(audit.rows[0]?.target_type, 'tenant_invoice', 'worker should audit overdue invoice processing'); assert.equal(audit.rows[0]?.target_id, ids.invoice, 'worker audit target should be the invoice id'); assert.equal(audit.rows[0]?.details?.reminderCreated, true, 'worker audit should record reminder creation'); const secondOutput = await runWorkerOnce(); assert.match(secondOutput, /markedOverdue=0/, 'second worker run should not re-mark overdue status for processed candidates'); const reminderCount = await pool.query( ` select count(*)::integer as count from public.tenant_invoice_reminders where tenant_id = $1 and invoice_id = $2 `, [ids.tenant, ids.invoice], ); assert.equal(Number(reminderCount.rows[0]?.count), 1, 'worker should be idempotent for daily reminders'); console.log('Platform dunning worker integration test complete.'); } finally { await cleanup(pool).catch(() => {}); await pool.end(); } } main().catch(error => { console.error(error); process.exit(1); });