forked from wangziqi/gongxue-base
139 lines
5.4 KiB
JavaScript
139 lines
5.4 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { parseWorkerCli, resolveWorkerMonth } from '../apps/worker/src/cli.ts';
|
|
|
|
const root = process.cwd();
|
|
|
|
function read(relativePath) {
|
|
return fs.readFileSync(path.join(root, relativePath), 'utf8').replace(/\r\n/g, '\n');
|
|
}
|
|
|
|
assert.throws(
|
|
() => parseWorkerCli(['--loop']),
|
|
/--job is required exactly once/,
|
|
'a production loop without an explicit job must fail closed',
|
|
);
|
|
assert.deepEqual(parseWorkerCli(['--loop', '--job', 'crm']), { job: 'crm', loop: true, month: undefined });
|
|
assert.throws(
|
|
() => parseWorkerCli(['--loop', '--job', 'platform-billing']),
|
|
/periodic and must be scheduled with --once/,
|
|
);
|
|
assert.deepEqual(
|
|
parseWorkerCli(['--once', '--job', 'platform-usage', '--month', 'previous']),
|
|
{ job: 'platform-usage', loop: false, month: resolveWorkerMonth('previous') },
|
|
);
|
|
assert.throws(() => parseWorkerCli(['--once', '--job', 'assets', '--month', 'previous']), /only supported/);
|
|
assert.throws(() => parseWorkerCli(['--once', '--job', 'crm', '--unknown']), /Unknown worker option/);
|
|
assert.throws(() => parseWorkerCli(['--once', '--job', 'crm', 'stray']), /Unexpected worker argument/);
|
|
|
|
const workerService = read('scripts/deploy/systemd/tiku-worker@.service');
|
|
const periodicService = read('scripts/deploy/systemd/tiku-worker-job@.service');
|
|
const monthlyService = read('scripts/deploy/systemd/tiku-worker-monthly-usage.service');
|
|
const target = read('scripts/deploy/systemd/tiku-workers.target');
|
|
const workerEnv = read('scripts/deploy/env/worker.env.example');
|
|
const workerConfig = read('apps/worker/src/config.ts');
|
|
const sharedDbConfig = read('packages/db/src/index.ts');
|
|
|
|
assert.match(workerService, /ExecStart=.*--loop --job %i/);
|
|
assert.match(periodicService, /Type=oneshot/);
|
|
assert.match(periodicService, /ExecStart=.*--once --job %i/);
|
|
assert.match(monthlyService, /--job platform-usage --month previous/);
|
|
assert.match(monthlyService, /--job platform-usage-overage --month previous/);
|
|
|
|
const continuousJobs = [
|
|
'crm',
|
|
'commerce',
|
|
'provider-bills',
|
|
'platform-dunning-notifications',
|
|
'platform-audit-notifications',
|
|
'assets',
|
|
'imports',
|
|
'public-banks',
|
|
'exports',
|
|
];
|
|
for (const job of continuousJobs) {
|
|
assert.match(target, new RegExp(`Requires=tiku-worker@${job}\\.service`), `missing continuous worker ${job}`);
|
|
}
|
|
|
|
const timerNames = [
|
|
'platform-billing',
|
|
'platform-usage',
|
|
'platform-dunning',
|
|
'platform-audit-alerts',
|
|
'student-supervision',
|
|
'monthly-usage',
|
|
];
|
|
for (const name of timerNames) {
|
|
const timerPath = `scripts/deploy/systemd/tiku-worker-${name}.timer`;
|
|
assert.ok(fs.existsSync(path.join(root, timerPath)), `missing timer ${timerPath}`);
|
|
assert.match(target, new RegExp(`Wants=tiku-worker-${name}\\.timer`), `target must want ${name} timer`);
|
|
const timer = read(timerPath);
|
|
if (timer.includes('OnCalendar=')) {
|
|
assert.match(timer, /\nPersistent=true\n/, `${timerPath} must catch up after downtime`);
|
|
} else {
|
|
assert.match(timer, /\nOnBootSec=/, `${timerPath} must resume its monotonic cadence after boot`);
|
|
}
|
|
assert.match(timer, /\nUnit=tiku-worker-(?:job@[^\n]+|monthly-usage\.service)\n/, `${timerPath} must name an explicit service`);
|
|
}
|
|
|
|
const envKeys = new Set(
|
|
[
|
|
...workerConfig.matchAll(/env(?:String|Number|Boolean|List)\(\s*'([A-Z0-9_]+)'/g),
|
|
...sharedDbConfig.matchAll(/positiveEnvNumber\(\s*'([A-Z0-9_]+)'/g),
|
|
...sharedDbConfig.matchAll(/process\.env\.([A-Z0-9_]+)/g),
|
|
].map(match => match[1]),
|
|
);
|
|
const workerEnvKeys = [...workerEnv.matchAll(/^([A-Z][A-Z0-9_]*)=/gm)].map(match => match[1]);
|
|
for (const key of workerEnvKeys) {
|
|
if (['NODE_ENV', 'DATABASE_URL', 'DB_EXPECTED_RUNTIME_ROLE'].includes(key)) continue;
|
|
assert.ok(envKeys.has(key), `worker.env.example contains a key not read by worker config: ${key}`);
|
|
}
|
|
|
|
for (const key of [
|
|
'STORAGE_DEFAULT_BUCKET',
|
|
'WORKER_ASSET_SECURITY_SCAN_HTTP_ENDPOINT',
|
|
'WORKER_ASSET_SECURITY_SCAN_HTTP_TOKEN',
|
|
'WORKER_ASSET_SECURITY_SCAN_HTTP_TIMEOUT_MS',
|
|
'WORKER_CRM_POLL_INTERVAL_MS',
|
|
'WORKER_COMMERCE_POLL_INTERVAL_MS',
|
|
'WORKER_PROVIDER_BILL_POLL_INTERVAL_MS',
|
|
'WORKER_PLATFORM_DUNNING_NOTIFICATION_POLL_INTERVAL_MS',
|
|
'WORKER_PLATFORM_AUDIT_NOTIFICATION_POLL_INTERVAL_MS',
|
|
'WORKER_ASSET_POLL_INTERVAL_MS',
|
|
'WORKER_IMPORT_POLL_INTERVAL_MS',
|
|
'WORKER_PUBLIC_BANK_SYNC_POLL_INTERVAL_MS',
|
|
'WORKER_EXPORT_POLL_INTERVAL_MS',
|
|
]) {
|
|
assert.match(workerEnv, new RegExp(`^${key}=`, 'm'), `worker env must document ${key}`);
|
|
}
|
|
|
|
const schedulerEnvKeys = new Set([
|
|
'WORKER_PLATFORM_USAGE_MONTH',
|
|
'WORKER_PLATFORM_USAGE_OVERAGE_MONTH',
|
|
'ALIYUN_OSS_STS_TOKEN',
|
|
'EXPORT_PDF_FONT_PATH',
|
|
]);
|
|
const codeDefaults = new Map(
|
|
[...workerConfig.matchAll(/env(?:String|Number|Boolean|List)\(\s*'([A-Z0-9_]+)'\s*,\s*([^\n,)]+)/g)]
|
|
.map(match => [match[1], match[2].trim()]),
|
|
);
|
|
for (const line of workerEnv.split('\n')) {
|
|
const match = line.match(/^([A-Z][A-Z0-9_]*)=(.*)$/);
|
|
if (!match || schedulerEnvKeys.has(match[1])) continue;
|
|
if (codeDefaults.get(match[1]) === "''") {
|
|
assert.notEqual(match[2], '', `worker env must not leave required ${match[1]} empty`);
|
|
}
|
|
}
|
|
|
|
for (const legacyKey of [
|
|
'ALIYUN_OSS_BUCKET',
|
|
'ASSET_SECURITY_SCAN_ENDPOINT',
|
|
'ASSET_SECURITY_SCAN_TOKEN',
|
|
'WORKER_POLL_INTERVAL_MS',
|
|
]) {
|
|
assert.doesNotMatch(workerEnv, new RegExp(`^${legacyKey}=`, 'm'), `worker env must not expose unused ${legacyKey}`);
|
|
}
|
|
|
|
console.log('[PASS] worker CLI and production scheduling contract');
|