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); });