forked from wangziqi/gongxue-base
feat: add platform dunning notifications
This commit is contained in:
265
scripts/platform-dunning-notification-worker-integration-test.js
Normal file
265
scripts/platform-dunning-notification-worker-integration-test.js
Normal file
@@ -0,0 +1,265 @@
|
||||
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 ids = {
|
||||
tenant: '00000000-0000-0000-0000-00000000db01',
|
||||
invoice: '00000000-0000-0000-0000-00000000db02',
|
||||
reminder: '00000000-0000-0000-0000-00000000db03',
|
||||
};
|
||||
|
||||
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}/platform-dunning`,
|
||||
requests,
|
||||
close: () => new Promise(resolve => server.close(resolve)),
|
||||
};
|
||||
}
|
||||
|
||||
function runWorkerOnce() {
|
||||
const child = spawn(process.execPath, ['apps/worker/dist/apps/worker/src/index.js', '--once', '--job', 'platform-dunning-notifications'], {
|
||||
cwd: process.cwd(),
|
||||
env: {
|
||||
...process.env,
|
||||
DATABASE_URL: databaseUrl,
|
||||
WORKER_PLATFORM_DUNNING_NOTIFICATION_BATCH_SIZE: '20',
|
||||
WORKER_PLATFORM_DUNNING_NOTIFICATION_ALLOW_INSECURE_LOCALHOST: 'true',
|
||||
WORKER_PLATFORM_DUNNING_NOTIFICATION_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();
|
||||
});
|
||||
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-notifications batch enqueued=\d+/, 'worker output should include dunning notification summary');
|
||||
resolve(output);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function cleanup(pool) {
|
||||
await pool.query('delete from public.platform_dunning_notification_events where tenant_id = $1', [ids.tenant]);
|
||||
await pool.query("delete from public.platform_dunning_notification_channels where channel_code = 'worker_platform_dunning_test'");
|
||||
await pool.query("delete from app_private.platform_secrets where secret_scope = 'webhook' and secret_key = 'worker_platform_dunning_test'");
|
||||
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 seed(pool, webhookUrl) {
|
||||
await pool.query(
|
||||
`
|
||||
insert into public.tenants (id, slug, name, legal_name, status, mode, billing_status, metadata)
|
||||
values ($1, 'platform-dunning-notification-worker', '平台催缴通知租户', '平台催缴通知有限公司', 'active', 'saas', 'past_due', '{"source":"platform-dunning-notification-worker-test"}'::jsonb)
|
||||
`,
|
||||
[ids.tenant],
|
||||
);
|
||||
await pool.query(
|
||||
`
|
||||
insert into public.tenant_billing_profiles (
|
||||
tenant_id, billing_name, tax_id, contact_name, contact_phone, contact_email,
|
||||
invoice_title, invoice_type, metadata
|
||||
)
|
||||
values (
|
||||
$1, '平台催缴通知有限公司', 'TAX-DB-TEST', '财务负责人',
|
||||
'13800006666', 'finance-dunning@example.test',
|
||||
'平台催缴通知有限公司', 'normal_vat',
|
||||
'{"apiKey":"must-not-leak","nested":{"password":"must-not-leak"}}'::jsonb
|
||||
)
|
||||
on conflict (tenant_id)
|
||||
do update set contact_phone = excluded.contact_phone,
|
||||
contact_email = excluded.contact_email,
|
||||
metadata = excluded.metadata,
|
||||
updated_at = now()
|
||||
`,
|
||||
[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, 'DNWORKER202606300001', 'service_fee', 'overdue', 'CNY',
|
||||
880000, 880000, 0, 880000,
|
||||
current_date - interval '9 days', now(), 'platform dunning notification worker invoice',
|
||||
'{"source":"platform-dunning-notification-worker-test"}'::jsonb
|
||||
)
|
||||
`,
|
||||
[ids.invoice, ids.tenant],
|
||||
);
|
||||
await pool.query(
|
||||
`
|
||||
insert into public.tenant_invoice_reminders (
|
||||
id, tenant_id, invoice_id, reminder_type, channel, status,
|
||||
reminder_date, reminder_level, due_date, balance_cents_snapshot,
|
||||
message, metadata
|
||||
)
|
||||
values (
|
||||
$1, $2, $3, 'overdue', 'internal', 'pending',
|
||||
current_date, 2, current_date - interval '9 days', 880000,
|
||||
'请尽快跟进服务费催缴',
|
||||
'{"source":"worker-test","apiKey":"must-not-leak","nested":{"password":"must-not-leak"}}'::jsonb
|
||||
)
|
||||
`,
|
||||
[ids.reminder, ids.tenant, ids.invoice],
|
||||
);
|
||||
await pool.query(
|
||||
`
|
||||
insert into app_private.platform_secrets (secret_scope, secret_key, secret_value, provider, last_rotated_at)
|
||||
values ('webhook', 'worker_platform_dunning_test', 'platform-dunning-notification-secret', 'generic', now())
|
||||
on conflict (secret_scope, secret_key)
|
||||
do update set secret_value = excluded.secret_value,
|
||||
provider = excluded.provider,
|
||||
last_rotated_at = now(),
|
||||
updated_at = now()
|
||||
`,
|
||||
);
|
||||
await pool.query(
|
||||
`
|
||||
insert into public.platform_dunning_notification_channels (
|
||||
channel_code, name, enabled, provider, webhook_url, secret_ref,
|
||||
reminder_types, reminder_channels, min_reminder_level, tenant_ids, timeout_sec
|
||||
)
|
||||
values (
|
||||
'worker_platform_dunning_test', 'Worker 平台催缴通知', true, 'generic',
|
||||
$1, 'app_private.platform_secrets:webhook:worker_platform_dunning_test',
|
||||
array['overdue']::text[], array['internal']::text[], 1, array[$2::uuid], 5
|
||||
)
|
||||
on conflict (channel_code)
|
||||
do update set enabled = excluded.enabled,
|
||||
provider = excluded.provider,
|
||||
webhook_url = excluded.webhook_url,
|
||||
secret_ref = excluded.secret_ref,
|
||||
reminder_types = excluded.reminder_types,
|
||||
reminder_channels = excluded.reminder_channels,
|
||||
min_reminder_level = excluded.min_reminder_level,
|
||||
tenant_ids = excluded.tenant_ids,
|
||||
timeout_sec = excluded.timeout_sec,
|
||||
updated_at = now()
|
||||
`,
|
||||
[webhookUrl, ids.tenant],
|
||||
);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const webhook = await startWebhookServer();
|
||||
const pool = new pg.Pool({ connectionString: databaseUrl });
|
||||
try {
|
||||
await cleanup(pool);
|
||||
await seed(pool, webhook.url);
|
||||
|
||||
const firstOutput = await runWorkerOnce();
|
||||
assert.match(firstOutput, /sent=1/, 'worker should send one platform dunning notification');
|
||||
assert.equal(webhook.requests.length, 1, 'worker should call webhook exactly once');
|
||||
assert.equal(webhook.requests[0].body.event, 'platform.invoice.dunning_reminder', 'generic notification should use dunning event name');
|
||||
assert.equal(webhook.requests[0].body.reminder.id, ids.reminder, 'webhook body should include reminder id');
|
||||
assert.equal(webhook.requests[0].body.invoice.id, ids.invoice, 'webhook body should include invoice id');
|
||||
assert.equal(webhook.requests[0].body.tenant.id, ids.tenant, 'webhook body should include tenant id');
|
||||
assert.equal(webhook.requests[0].body.billingContact.phoneMasked, '138****6666', 'webhook body should mask phone');
|
||||
assert.equal(webhook.requests[0].body.billingContact.emailMasked, 'fi***@example.test', 'webhook body should mask email');
|
||||
assert.equal(webhook.requests[0].body.reminder.metadata.apiKey, '[REDACTED]', 'webhook body should redact token-like keys');
|
||||
assert.equal(webhook.requests[0].body.reminder.metadata.nested.password, '[REDACTED]', 'webhook body should redact nested password');
|
||||
assert.ok(!JSON.stringify(webhook.requests[0]).includes('must-not-leak'), 'webhook request must not leak sensitive values');
|
||||
assert.ok(!JSON.stringify(webhook.requests[0]).includes('platform-dunning-notification-secret'), 'webhook request must not leak signing secret');
|
||||
assert.ok(!JSON.stringify(webhook.requests[0]).includes('13800006666'), 'webhook request must not leak raw phone');
|
||||
assert.ok(!JSON.stringify(webhook.requests[0]).includes('finance-dunning@example.test'), 'webhook request must not leak raw email');
|
||||
|
||||
const events = await pool.query(
|
||||
`
|
||||
select status, attempts, last_http_code, sent_at, request_payload
|
||||
from public.platform_dunning_notification_events
|
||||
where reminder_id = $1
|
||||
limit 1
|
||||
`,
|
||||
[ids.reminder],
|
||||
);
|
||||
assert.equal(events.rowCount, 1, 'worker should create one dunning notification event');
|
||||
assert.equal(events.rows[0].status, 'sent', 'dunning notification event should be sent');
|
||||
assert.equal(events.rows[0].attempts, 1, 'dunning notification event should record one attempt');
|
||||
assert.equal(events.rows[0].last_http_code, 200, 'dunning notification event should record HTTP 200');
|
||||
assert.ok(events.rows[0].sent_at, 'dunning notification event should record sent_at');
|
||||
assert.ok(!JSON.stringify(events.rows[0].request_payload).includes('must-not-leak'), 'stored request payload should be redacted');
|
||||
assert.ok(!JSON.stringify(events.rows[0].request_payload).includes('platform-dunning-notification-secret'), 'stored request payload should not leak secret');
|
||||
assert.ok(!JSON.stringify(events.rows[0].request_payload).includes('13800006666'), 'stored request payload should not leak raw phone');
|
||||
|
||||
const reminder = await pool.query(
|
||||
`
|
||||
select status, sent_at, metadata
|
||||
from public.tenant_invoice_reminders
|
||||
where id = $1
|
||||
`,
|
||||
[ids.reminder],
|
||||
);
|
||||
assert.equal(reminder.rows[0]?.status, 'sent', 'worker should mark reminder sent after successful external notification');
|
||||
assert.ok(reminder.rows[0]?.sent_at, 'worker should record reminder sent_at');
|
||||
assert.equal(reminder.rows[0]?.metadata?.externalNotification?.channelCode, 'worker_platform_dunning_test', 'worker should record external notification metadata');
|
||||
|
||||
const secondOutput = await runWorkerOnce();
|
||||
assert.match(secondOutput, /sent=0/, 'second worker run should not resend sent dunning notification');
|
||||
assert.equal(webhook.requests.length, 1, 'worker should not duplicate sent dunning notification');
|
||||
|
||||
console.log('Platform dunning notification worker integration test complete.');
|
||||
} finally {
|
||||
await cleanup(pool).catch(() => {});
|
||||
await pool.end();
|
||||
await webhook.close();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(error => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user