Files
gongxue-base/scripts/platform-usage-worker-integration-test.js
2026-06-30 20:00:14 +08:00

438 lines
18 KiB
JavaScript

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-00000000a899',
authPlatformAdminUser: '00000000-0000-0000-0000-00000000a900',
platformAdminUser: '00000000-0000-0000-0000-00000000a901',
studentOne: '00000000-0000-0000-0000-00000000a902',
studentTwo: '00000000-0000-0000-0000-00000000a903',
disabledStudent: '00000000-0000-0000-0000-00000000a904',
adminUser: '00000000-0000-0000-0000-00000000a905',
questionOne: '00000000-0000-0000-0000-00000000a906',
questionTwo: '00000000-0000-0000-0000-00000000a907',
questionThree: '00000000-0000-0000-0000-00000000a908',
questionDraft: '00000000-0000-0000-0000-00000000a909',
assetOne: '00000000-0000-0000-0000-00000000a910',
assetTwo: '00000000-0000-0000-0000-00000000a911',
assetArchived: '00000000-0000-0000-0000-00000000a912',
assetUnsafe: '00000000-0000-0000-0000-00000000a913',
videoOne: '00000000-0000-0000-0000-00000000a914',
videoTwo: '00000000-0000-0000-0000-00000000a915',
videoInactive: '00000000-0000-0000-0000-00000000a916',
orderOne: '00000000-0000-0000-0000-00000000a917',
orderTwo: '00000000-0000-0000-0000-00000000a918',
orderPending: '00000000-0000-0000-0000-00000000a919',
orderOutside: '00000000-0000-0000-0000-00000000a920',
entitlementOne: '00000000-0000-0000-0000-00000000a921',
entitlementTwo: '00000000-0000-0000-0000-00000000a922',
entitlementExpired: '00000000-0000-0000-0000-00000000a923',
entitlementOutside: '00000000-0000-0000-0000-00000000a924',
answerOne: '00000000-0000-0000-0000-00000000a925',
answerTwo: '00000000-0000-0000-0000-00000000a926',
answerOutside: '00000000-0000-0000-0000-00000000a927',
};
function runWorkerOnce() {
const child = spawn(process.execPath, ['apps/worker/dist/apps/worker/src/index.js', '--once', '--job', 'platform-usage'], {
cwd: process.cwd(),
env: {
...process.env,
DATABASE_URL: databaseUrl,
WORKER_PLATFORM_USAGE_BATCH_SIZE: '1',
WORKER_PLATFORM_USAGE_MONTH: '2026-06',
WORKER_PLATFORM_USAGE_ID: 'platform-usage-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 batch processed=\d+/, 'worker output should include platform usage summary');
resolve(output);
} catch (error) {
reject(error);
}
});
});
}
async function cleanup(pool) {
await pool.query('delete from public.tenants where id = $1', [ids.tenant]);
await pool.query(
`
delete from public.platform_users
where id = any($1::uuid[])
`,
[[ids.platformAdminUser, ids.studentOne, ids.studentTwo, ids.disabledStudent, ids.adminUser]],
);
await pool.query('delete from auth.users where id = $1', [ids.authPlatformAdminUser]).catch(() => {});
}
async function createAuthUser(pool) {
await pool.query(
`
insert into auth.users (
id, aud, role, phone, phone_confirmed_at, raw_app_meta_data, raw_user_meta_data, created_at, updated_at
)
values (
$1, 'authenticated', 'authenticated', '13900000900', now(),
'{"provider":"phone","providers":["phone"],"app_role":"platform_admin"}'::jsonb,
'{}'::jsonb, now(), now()
)
on conflict (id)
do update set phone = excluded.phone, updated_at = now()
`,
[ids.authPlatformAdminUser],
).catch(() => {});
await pool.query(
`
insert into public.platform_users (
id, auth_user_id, username, phone, name, primary_role, status, platform_permissions, raw_profile
)
values (
$1, $2, 'usage_platform_admin', '13900000900', '用量平台管理员',
'platform_admin', 'active', '{"*":true}'::jsonb, '{"source":"platform-usage-worker-test"}'::jsonb
)
`,
[ids.platformAdminUser, ids.authPlatformAdminUser],
);
}
async function createFixture(pool) {
await createAuthUser(pool);
await pool.query(
`
insert into public.tenants (
id, slug, name, legal_name, status, mode, billing_status, metadata, created_at, updated_at
)
values (
$1, 'platform-usage-worker', '平台用量采集测试租户', '平台用量采集测试有限公司',
'active', 'saas', 'active', '{"source":"platform-usage-worker-test"}'::jsonb,
'2000-01-01 00:00:00+00', now()
)
`,
[ids.tenant],
);
await pool.query(
`
insert into public.platform_users (id, username, phone, name, primary_role, raw_profile)
values
($1, 'usage_student_1', '13900000901', '用量学生一', 'student', '{"source":"platform-usage-worker-test"}'::jsonb),
($2, 'usage_student_2', '13900000902', '用量学生二', 'student', '{"source":"platform-usage-worker-test"}'::jsonb),
($3, 'usage_student_disabled', '13900000903', '禁用学生', 'student', '{"source":"platform-usage-worker-test"}'::jsonb),
($4, 'usage_admin', '13900000904', '用量管理员', 'tenant_admin', '{"source":"platform-usage-worker-test"}'::jsonb)
`,
[ids.studentOne, ids.studentTwo, ids.disabledStudent, ids.adminUser],
);
await pool.query(
`
insert into public.tenant_memberships (tenant_id, user_id, role, status, permissions)
values
($1, $2, 'student', 'active', '{}'::jsonb),
($1, $3, 'student', 'active', '{}'::jsonb),
($1, $4, 'student', 'disabled', '{}'::jsonb),
($1, $5, 'tenant_admin', 'active', '{"platform:usage:read":false}'::jsonb)
`,
[ids.tenant, ids.studentOne, ids.studentTwo, ids.disabledStudent, ids.adminUser],
);
await pool.query(
`
insert into public.student_profiles (tenant_id, user_id, stats, progress)
values
($1, $2, '{}'::jsonb, '{}'::jsonb),
($1, $3, '{}'::jsonb, '{}'::jsonb)
`,
[ids.tenant, ids.studentOne, ids.studentTwo],
);
await pool.query(
`
insert into public.questions (id, tenant_id, legacy_id, type, status)
values
($1, $5, 'usage-q-1', 'choice', 'published'),
($2, $5, 'usage-q-2', 'choice', 'published'),
($3, $5, 'usage-q-3', 'choice', 'published'),
($4, $5, 'usage-q-draft', 'choice', 'draft')
`,
[ids.questionOne, ids.questionTwo, ids.questionThree, ids.questionDraft, ids.tenant],
);
await pool.query(
`
insert into public.answer_records (
id, tenant_id, user_id, question_id, legacy_id, selected_options, is_correct, answered_at
)
values
($1, $4, $5, $7, 'usage-answer-1', '[0]'::jsonb, true, '2026-06-10 08:00:00+00'),
($2, $4, $6, $8, 'usage-answer-2', '[1]'::jsonb, false, '2026-06-15 08:00:00+00'),
($3, $4, $5, $9, 'usage-answer-outside', '[0]'::jsonb, true, '2026-05-15 08:00:00+00')
`,
[
ids.answerOne,
ids.answerTwo,
ids.answerOutside,
ids.tenant,
ids.studentOne,
ids.studentTwo,
ids.questionOne,
ids.questionTwo,
ids.questionThree,
],
);
await pool.query(
`
insert into public.content_assets (
id, tenant_id, legacy_id, asset_key, title, asset_type, storage_provider,
bucket, object_key, file_name, mime_type, file_size_bytes,
visibility, status, upload_status, verified_size_bytes,
security_scan_status, security_scan_summary, source
)
values
($1, $5, 'usage-asset-1', 'usage/asset-1.pdf', '已验证资料', 'pdf', 'local_dev',
'tenant-assets', 'usage/asset-1.pdf', 'asset-1.pdf', 'application/pdf', 1073741824,
'tenant', 'active', 'verified', 1073741824, 'passed', '{"riskLevel":"none"}'::jsonb, 'test'),
($2, $5, 'usage-asset-2', 'usage/asset-2.pdf', '外链资料', 'pdf', 'external_url',
null, null, 'asset-2.pdf', 'application/pdf', 536870912,
'tenant', 'active', 'not_required', null, 'not_required', '{}'::jsonb, 'test'),
($3, $5, 'usage-asset-archived', 'usage/asset-archived.pdf', '归档资料', 'pdf', 'local_dev',
'tenant-assets', 'usage/asset-archived.pdf', 'asset-archived.pdf', 'application/pdf', 5368709120,
'tenant', 'archived', 'verified', 5368709120, 'passed', '{"riskLevel":"none"}'::jsonb, 'test'),
($4, $5, 'usage-asset-unsafe', 'usage/asset-unsafe.pdf', '未通过扫描资料', 'pdf', 'local_dev',
'tenant-assets', 'usage/asset-unsafe.pdf', 'asset-unsafe.pdf', 'application/pdf', 2147483648,
'tenant', 'active', 'verified', 2147483648, 'failed', '{"riskLevel":"high"}'::jsonb, 'test')
`,
[ids.assetOne, ids.assetTwo, ids.assetArchived, ids.assetUnsafe, ids.tenant],
);
await pool.query(
`
insert into public.video_explanations (
id, tenant_id, legacy_id, title, video_url, duration_seconds, is_active, access_mode, play_count
)
values
($1, $4, 'usage-video-1', '用量视频一', 'https://example.test/video-1.mp4', 120, true, 'svip', 0),
($2, $4, 'usage-video-2', '用量视频二', 'https://example.test/video-2.mp4', 180, true, 'video_quota', 0),
($3, $4, 'usage-video-inactive', '停用视频', 'https://example.test/video-3.mp4', 90, false, 'svip', 0)
`,
[ids.videoOne, ids.videoTwo, ids.videoInactive, ids.tenant],
);
await pool.query(
`
insert into public.video_play_events (
tenant_id, user_id, video_id, question_id, play_token_hash,
status, access_mode, consumed_quota, signed_url_expires_at, metadata, created_at, updated_at
)
values
($1, $2, $4, $6, 'usage-play-1', 'issued', 'svip', 0, '2026-06-10 09:00:00+00', '{}'::jsonb, '2026-06-10 08:00:00+00', '2026-06-10 08:00:00+00'),
($1, $3, $5, $7, 'usage-play-2', 'completed', 'video_quota', 1, '2026-06-12 09:00:00+00', '{}'::jsonb, '2026-06-12 08:00:00+00', '2026-06-12 08:00:00+00'),
($1, $2, $5, $7, 'usage-play-3', 'started', 'video_quota', 2, '2026-06-20 09:00:00+00', '{}'::jsonb, '2026-06-20 08:00:00+00', '2026-06-20 08:00:00+00'),
($1, $2, $4, $6, 'usage-play-outside', 'issued', 'svip', 1, '2026-05-10 09:00:00+00', '{}'::jsonb, '2026-05-10 08:00:00+00', '2026-05-10 08:00:00+00')
`,
[
ids.tenant,
ids.studentOne,
ids.studentTwo,
ids.videoOne,
ids.videoTwo,
ids.questionOne,
ids.questionTwo,
],
);
await pool.query(
`
insert into public.orders (
id, tenant_id, user_id, legacy_id, order_no, status, product_type,
product_name, amount_cents, pay_method, pay_provider, paid_at, created_at, updated_at
)
values
($1, $5, $6, 'usage-order-1', 'USAGE202606001', 'paid', 'svip', '用量套餐一', 1000, 'wechat', 'wechat_pay', '2026-06-05 08:00:00+00', '2026-06-05 07:00:00+00', '2026-06-05 08:00:00+00'),
($2, $5, $7, 'usage-order-2', 'USAGE202606002', 'paid', 'svip', '用量套餐二', 2500, 'alipay', 'alipay', '2026-06-18 08:00:00+00', '2026-06-18 07:00:00+00', '2026-06-18 08:00:00+00'),
($3, $5, $6, 'usage-order-pending', 'USAGE202606003', 'pending', 'svip', '待支付套餐', 9000, null, null, null, '2026-06-20 07:00:00+00', '2026-06-20 07:00:00+00'),
($4, $5, $6, 'usage-order-outside', 'USAGE202607001', 'paid', 'svip', '七月套餐', 7000, 'wechat', 'wechat_pay', '2026-07-01 08:00:00+00', '2026-07-01 07:00:00+00', '2026-07-01 08:00:00+00')
`,
[ids.orderOne, ids.orderTwo, ids.orderPending, ids.orderOutside, ids.tenant, ids.studentOne, ids.studentTwo],
);
await pool.query(
`
insert into public.entitlements (
id, tenant_id, user_id, entitlement_type, scope_type,
source_type, source_id, starts_at, expires_at, status, metadata
)
values
($1, $5, $6, 'svip', 'tenant', 'order', $8, '2026-06-01 00:00:00+00', null, 'active', '{}'::jsonb),
($2, $5, $7, 'svip', 'tenant', 'order', $9, '2026-05-01 00:00:00+00', '2026-06-10 00:00:00+00', 'active', '{}'::jsonb),
($3, $5, $6, 'svip', 'tenant', 'order', $8, '2026-05-01 00:00:00+00', '2026-06-10 00:00:00+00', 'expired', '{}'::jsonb),
($4, $5, $6, 'svip', 'tenant', 'order', $10, '2026-07-01 00:00:00+00', null, 'active', '{}'::jsonb)
`,
[
ids.entitlementOne,
ids.entitlementTwo,
ids.entitlementExpired,
ids.entitlementOutside,
ids.tenant,
ids.studentOne,
ids.studentTwo,
ids.orderOne,
ids.orderTwo,
ids.orderOutside,
],
);
await pool.query(
`
insert into public.tenant_usage_records (
tenant_id, metric_key, metric_value, period_start, period_end, metadata
)
values ($1, 'students', 999, '2026-06-01', '2026-06-30', '{"source":"manual_adjustment","note":"must not be overwritten"}'::jsonb)
`,
[ids.tenant],
);
}
async function usageMap(pool) {
const records = await pool.query(
`
select metric_key as "metricKey", metric_value as "metricValue", metadata
from public.tenant_usage_records
where tenant_id = $1
and period_start = '2026-06-01'
and period_end = '2026-06-30'
and metadata->>'source' = 'platform_usage_worker'
order by metric_key asc
`,
[ids.tenant],
);
return new Map(records.rows.map(row => [row.metricKey, row]));
}
function assertMetric(metrics, key, expected) {
assert.ok(metrics.has(key), `metric ${key} should exist`);
assert.equal(Number(metrics.get(key).metricValue), expected, `metric ${key} should match`);
}
async function main() {
const pool = new pg.Pool({ connectionString: databaseUrl });
try {
await cleanup(pool);
await createFixture(pool);
const firstOutput = await runWorkerOnce();
assert.match(firstOutput, /processed=\d+/, 'worker should process active tenants');
assert.match(firstOutput, /created=\d+/, 'first worker run should create usage metrics');
assert.match(firstOutput, /failed=0/, 'first worker run should not fail');
const firstMetrics = await usageMap(pool);
assert.equal(firstMetrics.size, 11, 'worker should create one record per supported metric');
assertMetric(firstMetrics, 'students', 2);
assertMetric(firstMetrics, 'active_students', 2);
assertMetric(firstMetrics, 'questions', 3);
assertMetric(firstMetrics, 'assets', 3);
assertMetric(firstMetrics, 'videos', 2);
assertMetric(firstMetrics, 'video_plays', 3);
assertMetric(firstMetrics, 'video_quota_consumed', 3);
assertMetric(firstMetrics, 'paid_orders', 2);
assertMetric(firstMetrics, 'paid_order_amount_cents', 3500);
assertMetric(firstMetrics, 'active_entitlements', 2);
assert.ok(Math.abs(Number(firstMetrics.get('storage_gb').metricValue) - 1.5) < 0.000001, 'storage_gb should use verified safe asset size');
assert.equal(firstMetrics.get('storage_gb').metadata?.bytes, 1610612736, 'storage metric should keep bytes in metadata');
assert.equal(firstMetrics.get('students').metadata?.workerId, 'platform-usage-integration-test', 'metadata should record worker id');
const manualRecords = await pool.query(
`
select metric_value as "metricValue", metadata
from public.tenant_usage_records
where tenant_id = $1
and metric_key = 'students'
and metadata->>'source' = 'manual_adjustment'
`,
[ids.tenant],
);
assert.equal(manualRecords.rowCount, 1, 'manual usage record should remain append-only');
assert.equal(Number(manualRecords.rows[0]?.metricValue), 999, 'manual usage value should not be overwritten by worker');
const secondOutput = await runWorkerOnce();
assert.match(secondOutput, /created=0/, 'second worker run should not duplicate usage metrics');
assert.match(secondOutput, /updated=\d+/, 'second worker run should update existing usage metrics');
const workerRecordCount = await pool.query(
`
select count(*)::integer as count
from public.tenant_usage_records
where tenant_id = $1
and period_start = '2026-06-01'
and period_end = '2026-06-30'
and metadata->>'source' = 'platform_usage_worker'
`,
[ids.tenant],
);
assert.equal(workerRecordCount.rows[0]?.count, 11, 'worker should keep exactly one record per metric after rerun');
const detailUsageResult = await pool.query(
`
select metric_key as "metricKey", metric_value as "metricValue", metadata
from public.tenant_usage_records
where tenant_id = $1
and metric_key = 'students'
and period_start = '2026-06-01'
and period_end = '2026-06-30'
order by created_at desc
`,
[ids.tenant],
);
const detailUsage = detailUsageResult.rows;
assert.ok(
detailUsage.some(item => item.metricKey === 'students' && item.metadata?.source === 'platform_usage_worker' && Number(item.metricValue) === 2),
'usage records should include generated worker usage',
);
assert.ok(
detailUsage.some(item => item.metricKey === 'students' && item.metadata?.source === 'manual_adjustment' && Number(item.metricValue) === 999),
'usage records should keep manual usage adjustment records for audit',
);
const audit = await pool.query(
`
select action, details
from public.audit_logs
where tenant_id = $1
and action = 'platform.usage.worker_collected'
order by created_at desc
limit 1
`,
[ids.tenant],
);
assert.equal(audit.rows[0]?.details?.metricKeys?.length, 11, 'worker should audit collected metric keys');
console.log('Platform usage worker integration test complete.');
} finally {
await cleanup(pool).catch(() => {});
await pool.end();
}
}
main().catch(error => {
console.error(error);
process.exit(1);
});