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-00000000c901', noOverageTenant: '00000000-0000-0000-0000-00000000c902', subscription: '00000000-0000-0000-0000-00000000c903', noOverageSubscription: '00000000-0000-0000-0000-00000000c904', }; function runWorkerOnce() { const child = spawn(process.execPath, ['apps/worker/dist/apps/worker/src/index.js', '--once', '--job', 'platform-usage-overage'], { cwd: process.cwd(), env: { ...process.env, DATABASE_URL: databaseUrl, WORKER_PLATFORM_USAGE_OVERAGE_BATCH_SIZE: '20', WORKER_PLATFORM_USAGE_OVERAGE_MONTH: '2026-07', WORKER_PLATFORM_USAGE_OVERAGE_DUE_DAYS: '21', WORKER_PLATFORM_USAGE_OVERAGE_ID: 'platform-usage-overage-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-usage-overage batch processed=\d+/, 'worker output should include usage overage summary'); resolve(output); } catch (error) { reject(error); } }); }); } function runWorkerFailureOnce() { const child = spawn(process.execPath, ['apps/worker/dist/apps/worker/src/index.js', '--once', '--job', 'platform-usage-overage'], { cwd: process.cwd(), env: { ...process.env, DATABASE_URL: databaseUrl, WORKER_PLATFORM_USAGE_OVERAGE_BATCH_SIZE: '20', WORKER_PLATFORM_USAGE_OVERAGE_MONTH: '2026-99', WORKER_PLATFORM_USAGE_OVERAGE_ID: 'platform-usage-overage-failure-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.notEqual(code, 0, `invalid usage overage month should fail the worker\n${output}`); assert.match(output, /Invalid platform usage overage month/, 'worker failure output should explain invalid month'); resolve(output); } catch (error) { reject(error); } }); }); } function runAuditAlertWorkerOnce() { const child = spawn(process.execPath, ['apps/worker/dist/apps/worker/src/index.js', '--once', '--job', 'platform-audit-alerts'], { cwd: process.cwd(), env: { ...process.env, DATABASE_URL: databaseUrl, WORKER_PLATFORM_AUDIT_ALERT_BATCH_SIZE: '20', WORKER_PLATFORM_AUDIT_ALERT_LOOKBACK_DAYS: '30', WORKER_PLATFORM_AUDIT_ALERT_ID: 'platform-usage-overage-alert-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, `audit alert worker should exit 0\n${output}`); assert.match(output, /platform-audit-alerts batch processed=\d+/, 'audit alert worker output should include summary'); resolve(output); } catch (error) { reject(error); } }); }); } function dateOnly(value) { if (value instanceof Date) return value.toISOString().slice(0, 10); return String(value || '').slice(0, 10); } async function cleanup(pool) { await pool.query('delete from public.audit_logs where tenant_id = any($1::uuid[]) or action = any($2::text[])', [ [ids.tenant, ids.noOverageTenant], ['platform.invoice.usage_overage_batch_created', 'platform.invoice.usage_overage_worker_failed'], ]); await pool.query('delete from public.tenant_invoice_payments where tenant_id = any($1::uuid[])', [[ids.tenant, ids.noOverageTenant]]); await pool.query('delete from public.tenant_invoice_items where tenant_id = any($1::uuid[])', [[ids.tenant, ids.noOverageTenant]]); await pool.query('delete from public.tenant_invoices where tenant_id = any($1::uuid[])', [[ids.tenant, ids.noOverageTenant]]); await pool.query('delete from public.tenant_usage_records where tenant_id = any($1::uuid[])', [[ids.tenant, ids.noOverageTenant]]); await pool.query('delete from public.tenant_subscriptions where tenant_id = any($1::uuid[])', [[ids.tenant, ids.noOverageTenant]]); await pool.query('delete from public.tenants where id = any($1::uuid[])', [[ids.tenant, ids.noOverageTenant]]); } async function createFixture(pool) { await pool.query( ` insert into public.tenants (id, slug, name, legal_name, status, mode, billing_status, metadata) values ($1, 'platform-usage-overage-worker', '平台超额账单测试租户', '平台超额账单测试有限公司', 'active', 'saas', 'active', '{"source":"platform-usage-overage-worker-test"}'::jsonb), ($2, 'platform-usage-overage-none', '平台无超额测试租户', '平台无超额测试有限公司', 'active', 'saas', 'active', '{"source":"platform-usage-overage-worker-test"}'::jsonb) `, [ids.tenant, ids.noOverageTenant], ); await pool.query( ` insert into public.tenant_subscriptions ( id, tenant_id, plan_code, status, starts_at, expires_at, billing_cycle, amount_cents, metadata ) values ( $1, $3, 'starter_yearly', 'active', '2026-01-01 00:00:00+00', '2026-12-31 23:59:59+00', 'yearly', 980000, '{"includedQuotas":{"students":100,"storageGb":2},"overagePrices":{"students":{"unitAmountCents":200,"unitSize":10},"storage_gb":{"unitAmountCents":1200,"unitSize":1}},"source":"platform-usage-overage-worker-test"}'::jsonb ), ( $2, $4, 'starter_yearly', 'active', '2026-01-01 00:00:00+00', '2026-12-31 23:59:59+00', 'yearly', 980000, '{"includedQuotas":{"students":100,"storageGb":2},"overagePrices":{"students":{"unitAmountCents":200,"unitSize":10},"storage_gb":{"unitAmountCents":1200,"unitSize":1}},"source":"platform-usage-overage-worker-test"}'::jsonb ) `, [ids.subscription, ids.noOverageSubscription, ids.tenant, ids.noOverageTenant], ); await pool.query( ` insert into public.tenant_usage_records ( tenant_id, metric_key, metric_value, period_start, period_end, metadata, created_at ) values ($1, 'students', 125, '2026-07-01', '2026-07-31', '{"source":"platform_usage_worker"}'::jsonb, '2026-08-01 00:00:00+00'), ($1, 'storage_gb', 3.5, '2026-07-01', '2026-07-31', '{"source":"platform_usage_worker"}'::jsonb, '2026-08-01 00:00:01+00'), ($1, 'questions', 4000, '2026-07-01', '2026-07-31', '{"source":"platform_usage_worker"}'::jsonb, '2026-08-01 00:00:02+00'), ($2, 'students', 90, '2026-07-01', '2026-07-31', '{"source":"platform_usage_worker"}'::jsonb, '2026-08-01 00:00:00+00'), ($2, 'storage_gb', 1.75, '2026-07-01', '2026-07-31', '{"source":"platform_usage_worker"}'::jsonb, '2026-08-01 00:00:01+00') `, [ids.tenant, ids.noOverageTenant], ); } async function main() { const pool = new pg.Pool({ connectionString: databaseUrl }); try { await cleanup(pool); await createFixture(pool); const firstOutput = await runWorkerOnce(); assert.match(firstOutput, /created=1/, 'first worker run should create one usage overage invoice'); assert.match(firstOutput, /skipped=0/, 'first worker run should not skip existing overage invoices'); assert.match(firstOutput, /totalCents=3000/, 'worker should report the generated overage amount'); const invoices = await pool.query( ` select id, invoice_no, invoice_type, status, total_cents, balance_cents, billing_period_start::text as billing_period_start, billing_period_end::text as billing_period_end, due_date::text as due_date, note, metadata from public.tenant_invoices where tenant_id = $1 and invoice_type = 'usage_overage' `, [ids.tenant], ); assert.equal(invoices.rowCount, 1, 'worker should create exactly one usage overage invoice'); const invoice = invoices.rows[0]; assert.equal(invoice.status, 'issued', 'worker overage invoice should be issued'); assert.equal(Number(invoice.total_cents), 3000, 'worker invoice should include all overage items'); assert.equal(Number(invoice.balance_cents), 3000, 'worker invoice should start unpaid'); assert.equal(dateOnly(invoice.billing_period_start), '2026-07-01', 'invoice period start should match usage month'); assert.equal(dateOnly(invoice.billing_period_end), '2026-07-31', 'invoice period end should match usage month'); assert.equal(invoice.metadata?.source, 'usage_overage_auto', 'invoice metadata should record auto source'); assert.equal(invoice.metadata?.workerId, 'platform-usage-overage-integration-test', 'invoice metadata should record worker id'); const items = await pool.query( ` select item_type, description, quantity, unit_amount_cents, amount_cents, metadata from public.tenant_invoice_items where tenant_id = $1 and invoice_id = $2 order by metadata->>'metricKey' `, [ids.tenant, invoice.id], ); assert.equal(items.rowCount, 2, 'worker invoice should include two overage items'); const studentItem = items.rows.find(item => item.metadata?.metricKey === 'students'); const storageItem = items.rows.find(item => item.metadata?.metricKey === 'storage_gb'); assert.ok(studentItem, 'student overage item should exist'); assert.ok(storageItem, 'storage overage item should exist'); assert.equal(Number(studentItem.quantity), 3, '125 students over quota 100 should bill three 10-student units'); assert.equal(Number(studentItem.amount_cents), 600, 'student overage amount should match unit pricing'); assert.equal(Number(storageItem.quantity), 2, '3.5GB over quota 2GB should bill two 1GB units'); assert.equal(Number(storageItem.amount_cents), 2400, 'storage overage amount should match unit pricing'); const noOverageInvoice = await pool.query( ` select count(*)::integer as count from public.tenant_invoices where tenant_id = $1 and invoice_type = 'usage_overage' `, [ids.noOverageTenant], ); assert.equal(noOverageInvoice.rows[0]?.count, 0, 'tenant under quota should not get an overage invoice'); const audit = await pool.query( ` select action, target_type, target_id, details from public.audit_logs where tenant_id = $1 and action = 'platform.invoice.usage_overage_created' order by created_at desc limit 1 `, [ids.tenant], ); assert.equal(audit.rows[0]?.target_type, 'tenant_invoice', 'worker should audit generated invoice'); assert.equal(audit.rows[0]?.target_id, invoice.id, 'worker audit should target invoice id'); assert.equal(audit.rows[0]?.details?.workerId, 'platform-usage-overage-integration-test', 'worker audit should include worker id'); const secondOutput = await runWorkerOnce(); assert.match(secondOutput, /created=0/, 'second worker run should not duplicate usage overage invoice'); assert.match(secondOutput, /skipped=1/, 'second worker run should report the existing invoice as skipped'); const invoiceCount = await pool.query( ` select count(*)::integer as count from public.tenant_invoices where tenant_id = $1 and invoice_type = 'usage_overage' `, [ids.tenant], ); assert.equal(invoiceCount.rows[0]?.count, 1, 'worker should be idempotent for usage overage invoices'); await runWorkerFailureOnce(); const failureAudit = await pool.query( ` select id, action, target_type, target_id, details from public.audit_logs where action = 'platform.invoice.usage_overage_worker_failed' order by created_at desc limit 1 `, ); assert.equal(failureAudit.rowCount, 1, 'failed usage overage worker should create a platform audit log'); assert.equal(failureAudit.rows[0]?.target_type, 'tenant_invoice_batch', 'failure audit should target the invoice batch'); assert.equal(failureAudit.rows[0]?.target_id, '2026-99', 'failure audit should retain the failed month as target'); assert.equal(failureAudit.rows[0]?.details?.workerId, 'platform-usage-overage-failure-test', 'failure audit should record worker id'); assert.equal(failureAudit.rows[0]?.details?.error?.code, 'PLATFORM_USAGE_OVERAGE_WORKER_FAILED', 'failure audit should expose a stable error code'); assert.ok(!JSON.stringify(failureAudit.rows[0]?.details).match(/postgres:\/\/|password|secret/i), 'failure audit details must not leak secrets'); const alertOutput = await runAuditAlertWorkerOnce(); assert.match(alertOutput, /created=\d+/, 'audit alert worker should process the usage overage failure'); const failureAlert = await pool.query( ` select a.status, a.severity, a.action, a.target_type, a.target_id, a.details, r.code as rule_code from public.platform_audit_alerts a join public.platform_audit_alert_rules r on r.id = a.rule_id where a.audit_log_id = $1 `, [failureAudit.rows[0].id], ); assert.equal(failureAlert.rowCount, 1, 'usage overage worker failure should create one platform alert'); assert.equal(failureAlert.rows[0]?.status, 'open', 'failure alert should start open'); assert.equal(failureAlert.rows[0]?.severity, 'high', 'usage overage worker failure should be high severity'); assert.equal(failureAlert.rows[0]?.rule_code, 'platform_usage_overage_worker_failed', 'failure alert should use the usage overage failure rule'); assert.equal(failureAlert.rows[0]?.details?.auditDetails?.workerId, 'platform-usage-overage-failure-test', 'failure alert should preserve worker id'); assert.ok(!JSON.stringify(failureAlert.rows[0]?.details).match(/postgres:\/\/|password|secret/i), 'failure alert details must not leak secrets'); console.log('Platform usage overage worker integration test complete.'); } finally { await cleanup(pool).catch(() => {}); await pool.end(); } } main().catch(error => { console.error(error); process.exit(1); });