/** * 半自动任务真实 PostgreSQL 集成测试。 * * SEMI_AUTO_TASK_TEST_DATABASE_URL=postgres://... \ * npx tsx --test src/services/semi-auto/semi-auto-task.integration.test.ts */ import assert from 'node:assert/strict' import test from 'node:test' const TEST_DATABASE_URL = String(process.env.SEMI_AUTO_TASK_TEST_DATABASE_URL || '').trim() test( '人工渠道半自动任务可由客户入口和后台列表幂等回填', { skip: !TEST_DATABASE_URL ? '未设置 SEMI_AUTO_TASK_TEST_DATABASE_URL' : false }, async () => { process.env.DATABASE_URL = TEST_DATABASE_URL const { closeDb, query } = await import('../../db/client.js') const { ensureSemiAutoTasksForOrder, exportAdminSemiAutoCredential, listAdminSemiAutoTasks, listPublicSemiAutoTasks, requestAdminSemiAutoCustomerReauth, } = await import('./semi-auto-task-service.js') const suffix = `${Date.now()}_${Math.floor(Math.random() * 1_000_000)}` const orderNo = `IT-SEMI-AUTO-${suffix}` const skuKey = `半自动测试-${suffix}` let orderId = 0 let configId = 0 let credentialId = 0 try { const order = await query<{ id: number }>( ` INSERT INTO orders ( provider, platform, shop_id, shop_name, platform_order_id, order_status, pay_status, total_amount, paid_at, created_at, updated_at ) VALUES ( '91kaquan', 'kuaishou-91', $1, '半自动测试店', $2, 'paid', 'paid', 1000, NOW(), NOW(), NOW() ) RETURNING id `, [`semi-auto-shop-${suffix}`, orderNo], ) orderId = Number(order.rows[0]?.id || 0) assert.ok(orderId > 0) const item = await query<{ id: number }>( ` INSERT INTO order_items ( order_id, sku_code, sku_name, quantity, spec_json, item_snapshot_json, revenue_amount_fen, revenue_basis, created_at, updated_at ) VALUES ( $1, $2, $3, 1, $4::jsonb, $4::jsonb, 1000, 'order_total', NOW(), NOW() ) RETURNING id `, [orderId, `semi-${suffix}`, skuKey, JSON.stringify({ skuNick: skuKey })], ) const orderItemId = Number(item.rows[0]?.id || 0) assert.ok(orderItemId > 0) const fulfillment = await query<{ id: number }>( 'SELECT id FROM fulfillments WHERE order_item_id = $1 LIMIT 1', [orderItemId], ) const fulfillmentId = Number(fulfillment.rows[0]?.id || 0) assert.ok(fulfillmentId > 0) const config = await query<{ id: number }>( ` INSERT INTO manual_fulfillment_configs ( sku_key, platform_key, credit_quantity, cost_mode, enabled, note, channels_json, semi_auto_enabled, semi_auto_platform, semi_auto_config_json, created_at, updated_at ) VALUES ( $1, 'fund_cash', 100, 'fixed', TRUE, '集成测试', '[{"platformKey":"fund_cash","creditQuantity":100,"costMode":"fixed","enabled":true}]'::jsonb, TRUE, 'huya', '{"pageTitle":"虎牙授权测试"}'::jsonb, NOW(), NOW() ) RETURNING id `, [skuKey], ) configId = Number(config.rows[0]?.id || 0) assert.ok(configId > 0) await query( ` UPDATE fulfillments SET channel = 'manual', status = 'assigned', fulfillment_config_id = $2, source = 'manual_confirm', updated_at = NOW() WHERE id = $1 `, [fulfillmentId, configId], ) // 客服工作台先打开时也必须看到任务,不能依赖客户先访问 `/c/订单号`。 const adminList = await listAdminSemiAutoTasks({ keyword: orderNo }) assert.equal(adminList.pagination.total, 1) assert.equal(adminList.items[0]?.orderNo, orderNo) const first = await ensureSemiAutoTasksForOrder(orderNo) const second = await ensureSemiAutoTasksForOrder(orderNo) assert.equal(first.createdIds.length, 0) assert.equal(second.createdIds.length, 0) const tasks = await listPublicSemiAutoTasks(orderNo) assert.equal(tasks.length, 1) assert.equal(tasks[0]?.orderItemId, orderItemId) assert.equal(tasks[0]?.platform, 'huya') assert.equal(tasks[0]?.orderNo, orderNo) assert.equal(tasks[0]?.quantity, 1) assert.equal(tasks[0]?.semiAutoConfig.pageTitle, '虎牙授权测试') const taskId = Number(tasks[0]?.id || 0) const credential = await query<{ id: number }>( ` INSERT INTO semi_auto_credentials ( platform, platform_uid, platform_nickname, credential_payload_json, credential_source, credential_status, credential_updated_at, last_checked_at, last_check_error, created_at, updated_at ) VALUES ( 'huya', $1, '集成测试虎牙', '{"cookies":[]}', 'qr', 'valid', NOW(), NOW(), '', NOW(), NOW() ) RETURNING id `, [`it-huya-${suffix}`], ) credentialId = Number(credential.rows[0]?.id || 0) await query( ` UPDATE semi_auto_tasks SET credential_id = $2, connector_session_id = $3, login_status = 'authenticated', binding_status = 'not_started', execution_status = 'waiting_operator', customer_link_opened_at = NOW(), authenticated_at = NOW(), updated_at = NOW() WHERE id = $1 `, [taskId, credentialId, `it-huya-session-${suffix}`], ) const authenticatedList = await listAdminSemiAutoTasks({ keyword: orderNo }) assert.equal(authenticatedList.items[0]?.loginStatus, 'authenticated') assert.equal(authenticatedList.items[0]?.displayStatus.label, 'CK 已获取,等待客服处理') const exported = await exportAdminSemiAutoCredential(taskId) assert.equal(exported.credentialId, credentialId) assert.equal(exported.platform, 'huya') const reauthorized = await requestAdminSemiAutoCustomerReauth(taskId, { userId: 1, role: 'support', displayName: '集成测试客服', } as never) assert.equal(reauthorized.task.loginStatus, 'waiting_login') assert.equal(reauthorized.task.bindingStatus, 'not_started') assert.equal(reauthorized.task.executionStatus, 'waiting_customer') assert.equal(reauthorized.task.customerLinkOpenedAt, null) const retainedCredential = await query<{ credential_id: number }>( 'SELECT credential_id FROM semi_auto_tasks WHERE id = $1', [taskId], ) assert.equal(Number(retainedCredential.rows[0]?.credential_id), credentialId) assert.ok( reauthorized.events.some((event) => event.eventType === 'customer_reauth_requested'), ) } finally { if (orderId > 0) await query('DELETE FROM orders WHERE id = $1', [orderId]) if (credentialId > 0) { await query('DELETE FROM semi_auto_credentials WHERE id = $1', [credentialId]) } if (configId > 0) { await query('DELETE FROM manual_fulfillment_configs WHERE id = $1', [configId]) } await closeDb() } }, ) test( '半自动完成失败会连同财务确认一起回滚,成功后重复完成不重复扣成本', { skip: !TEST_DATABASE_URL ? '未设置 SEMI_AUTO_TASK_TEST_DATABASE_URL' : false }, async () => { process.env.DATABASE_URL = TEST_DATABASE_URL const { closeDb, query } = await import('../../db/client.js') const { completeAdminSemiAutoTask, ensureSemiAutoTasksForOrder } = await import('./semi-auto-task-service.js') const suffix = `${Date.now()}_${Math.floor(Math.random() * 1_000_000)}` const orderNo = `IT-SEMI-AUTO-ATOMIC-${suffix}` const skuKey = `半自动原子性-${suffix}` const triggerName = `trg_semi_auto_atomic_${suffix}` const functionName = `fn_semi_auto_atomic_${suffix}` let orderId = 0 let configId = 0 let adminId = 0 let credentialId = 0 let triggerCreated = false try { const admin = await query<{ id: number }>( `INSERT INTO admin_users (username, password_hash, role, status, created_at, updated_at) VALUES ($1, 'x', 'support', 'active', NOW(), NOW()) RETURNING id`, [`it_semi_auto_admin_${suffix}`], ) adminId = Number(admin.rows[0]?.id || 0) assert.ok(adminId > 0) const order = await query<{ id: number }>( ` INSERT INTO orders ( provider, platform, shop_id, shop_name, platform_order_id, order_status, pay_status, total_amount, paid_at, created_at, updated_at ) VALUES ( '91kaquan', 'kuaishou-91', $1, '半自动原子性测试店', $2, 'paid', 'paid', 1000, NOW(), NOW(), NOW() ) RETURNING id `, [`semi-auto-atomic-shop-${suffix}`, orderNo], ) orderId = Number(order.rows[0]?.id || 0) assert.ok(orderId > 0) const item = await query<{ id: number }>( ` INSERT INTO order_items ( order_id, sku_code, sku_name, quantity, spec_json, item_snapshot_json, revenue_amount_fen, revenue_basis, created_at, updated_at ) VALUES ( $1, $2, $3, 1, $4::jsonb, $4::jsonb, 1000, 'order_total', NOW(), NOW() ) RETURNING id `, [orderId, `semi-atomic-${suffix}`, skuKey, JSON.stringify({ skuNick: skuKey })], ) const orderItemId = Number(item.rows[0]?.id || 0) assert.ok(orderItemId > 0) const fulfillment = await query<{ id: number }>( 'SELECT id FROM fulfillments WHERE order_item_id = $1 LIMIT 1', [orderItemId], ) const fulfillmentId = Number(fulfillment.rows[0]?.id || 0) assert.ok(fulfillmentId > 0) const config = await query<{ id: number }>( ` INSERT INTO manual_fulfillment_configs ( sku_key, platform_key, credit_quantity, cost_mode, enabled, note, channels_json, semi_auto_enabled, semi_auto_platform, semi_auto_config_json, created_at, updated_at ) VALUES ( $1, 'on_account', 0, 'variable', TRUE, '原子性集成测试', '[{"platformKey":"on_account","creditQuantity":0,"costMode":"variable","enabled":true}]'::jsonb, TRUE, 'huya', '{}'::jsonb, NOW(), NOW() ) RETURNING id `, [skuKey], ) configId = Number(config.rows[0]?.id || 0) assert.ok(configId > 0) await query( ` UPDATE fulfillments SET channel = 'manual', status = 'assigned', fulfillment_config_id = $2, source = 'manual_confirm', updated_at = NOW() WHERE id = $1 `, [fulfillmentId, configId], ) const ensured = await ensureSemiAutoTasksForOrder(orderNo) const taskId = Number(ensured.createdIds[0] || 0) assert.ok(taskId > 0) const credential = await query<{ id: number }>( ` INSERT INTO semi_auto_credentials ( platform, platform_uid, platform_nickname, credential_payload_json, credential_source, credential_status, credential_updated_at, last_checked_at, last_check_error, created_at, updated_at ) VALUES ( 'huya', $1, '半自动原子性账号', '{"cookies":[]}', 'qr', 'valid', NOW(), NOW(), '', NOW(), NOW() ) RETURNING id `, [`it-atomic-huya-${suffix}`], ) credentialId = Number(credential.rows[0]?.id || 0) assert.ok(credentialId > 0) await query( ` UPDATE semi_auto_tasks SET credential_id = $2, login_status = 'authenticated', binding_status = 'not_started', execution_status = 'waiting_operator', authenticated_at = NOW(), updated_at = NOW() WHERE id = $1 `, [taskId, credentialId], ) await query( ` CREATE OR REPLACE FUNCTION ${functionName}() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.execution_status = 'completed' THEN RAISE EXCEPTION 'forced semi auto completion failure'; END IF; RETURN NEW; END; $$ `, ) await query( ` CREATE TRIGGER ${triggerName} BEFORE UPDATE OF execution_status ON semi_auto_tasks FOR EACH ROW EXECUTE FUNCTION ${functionName}() `, ) triggerCreated = true const session = { userId: adminId, role: 'support', displayName: '半自动原子性客服', } as never await assert.rejects( () => completeAdminSemiAutoTask( taskId, { costFen: 123, note: '触发任务更新失败以验证回滚' }, session, ), /forced semi auto completion failure/, ) const rolledBack = await query<{ execution_status: string snapshot_count: number bill_count: number fulfillment_channel: string }>( ` SELECT task.execution_status, (SELECT COUNT(*)::int FROM cost_snapshots cs WHERE cs.order_item_id = $2) AS snapshot_count, (SELECT COUNT(*)::int FROM on_account_bills oab WHERE oab.order_item_id = $2) AS bill_count, f.channel AS fulfillment_channel FROM semi_auto_tasks task JOIN fulfillments f ON f.id = task.fulfillment_id WHERE task.id = $1 `, [taskId, orderItemId], ) assert.equal(rolledBack.rows[0]?.execution_status, 'waiting_operator') assert.equal(Number(rolledBack.rows[0]?.snapshot_count), 0) assert.equal(Number(rolledBack.rows[0]?.bill_count), 0) assert.equal(rolledBack.rows[0]?.fulfillment_channel, 'manual') await query(`DROP TRIGGER ${triggerName} ON semi_auto_tasks`) triggerCreated = false await query(`DROP FUNCTION ${functionName}()`) const completed = await completeAdminSemiAutoTask( taskId, { costFen: 123, note: '事务重试成功' }, session, ) assert.equal(completed.task.executionStatus, 'completed') const afterCompletion = await query<{ snapshot_count: number; bill_count: number }>( ` SELECT (SELECT COUNT(*)::int FROM cost_snapshots cs WHERE cs.order_item_id = $1) AS snapshot_count, (SELECT COUNT(*)::int FROM on_account_bills oab WHERE oab.order_item_id = $1) AS bill_count `, [orderItemId], ) assert.equal(Number(afterCompletion.rows[0]?.snapshot_count), 1) assert.equal(Number(afterCompletion.rows[0]?.bill_count), 1) await completeAdminSemiAutoTask(taskId, { costFen: 123 }, session) const afterDuplicate = await query<{ snapshot_count: number }>( 'SELECT COUNT(*)::int AS snapshot_count FROM cost_snapshots WHERE order_item_id = $1', [orderItemId], ) assert.equal(Number(afterDuplicate.rows[0]?.snapshot_count), 1) } finally { if (triggerCreated) { await query(`DROP TRIGGER IF EXISTS ${triggerName} ON semi_auto_tasks`) } await query(`DROP FUNCTION IF EXISTS ${functionName}()`) if (orderId > 0) { await query( 'DELETE FROM cost_snapshots WHERE order_item_id IN (SELECT id FROM order_items WHERE order_id = $1)', [orderId], ) await query( 'DELETE FROM on_account_bills WHERE order_item_id IN (SELECT id FROM order_items WHERE order_id = $1)', [orderId], ) await query( 'DELETE FROM cost_exceptions WHERE subject_id IN (SELECT id FROM order_items WHERE order_id = $1)', [orderId], ) await query('DELETE FROM orders WHERE id = $1', [orderId]) } // 已过账总账凭证按财务规则不可物理删除;测试只清理可重建销售投影。 await query( `DELETE FROM sales_daily_facts WHERE stat_date = (NOW() AT TIME ZONE 'Asia/Shanghai')::date`, ) if (configId > 0) { await query('DELETE FROM manual_fulfillment_configs WHERE id = $1', [configId]) } if (credentialId > 0) { await query('DELETE FROM semi_auto_credentials WHERE id = $1', [credentialId]) } if (adminId > 0) { await query('DELETE FROM admin_users WHERE id = $1', [adminId]) } await closeDb() } }, ) test( '微博账号 CK 以账号级 Cookie JSON 保存并可加载', { skip: !TEST_DATABASE_URL ? '未设置 SEMI_AUTO_TASK_TEST_DATABASE_URL' : false }, async () => { process.env.DATABASE_URL = TEST_DATABASE_URL const { closeDb, query, withTransaction } = await import('../../db/client.js') const { loadSemiAutoCredential, upsertSemiAutoCredentialWithClient } = await import('./semi-auto-credential-service.js') const suffix = `${Date.now()}_${Math.floor(Math.random() * 1_000_000)}` let credentialId = 0 try { credentialId = await withTransaction((client) => upsertSemiAutoCredentialWithClient(client, { platform: 'weibo', source: 'sms', payload: { platformUid: `it-weibo-${suffix}`, nickname: '集成测试微博', cookies: [ { name: 'SUB', value: `secret-sub-${suffix}`, domain: '.weibo.com', path: '/', secure: true, httpOnly: true, }, ], }, }), ) const raw = await query<{ credential_payload_json: string credential_source: string credential_status: string }>( `SELECT credential_payload_json, credential_source, credential_status FROM semi_auto_credentials WHERE id = $1`, [credentialId], ) assert.equal(raw.rows[0]?.credential_source, 'sms') assert.equal(raw.rows[0]?.credential_status, 'valid') assert.match(raw.rows[0]?.credential_payload_json || '', /"cookies"/) const credential = await loadSemiAutoCredential(credentialId) assert.equal(credential?.platformUid, `it-weibo-${suffix}`) assert.equal(credential?.cookies[0]?.value, `secret-sub-${suffix}`) } finally { if (credentialId > 0) { await query('DELETE FROM semi_auto_credentials WHERE id = $1', [credentialId]) } await closeDb() } }, ) test( '开发 Mock 可生成半自动履约测试单并回填任务', { skip: !TEST_DATABASE_URL ? '未设置 SEMI_AUTO_TASK_TEST_DATABASE_URL' : false }, async () => { process.env.DATABASE_URL = TEST_DATABASE_URL process.env.ENABLE_DEV_MOCK = '1' const { closeDb, query } = await import('../../db/client.js') const { createSemiAutoMock } = await import('../dev-mock/dev-mock-service.js') const suffix = `${Date.now()}_${Math.floor(Math.random() * 1_000_000)}` const orderNo = `IT-MOCK-SA-${suffix}` const skuKey = `半自动Mock-${suffix}` let orderId = 0 let configId = 0 try { const result = await createSemiAutoMock({ orderNo, skuName: skuKey, quantity: 1, pageTitle: 'Mock 集成测试', connectorMode: 'embedded', }) orderId = result.orderId configId = result.configId assert.ok(result.orderItemId > 0) assert.ok(result.fulfillmentId > 0) assert.ok(result.taskId > 0) assert.equal(result.connectorMode, 'embedded') assert.equal(result.customerPath, `/c/${encodeURIComponent(orderNo)}`) const row = await query<{ channel: string fulfillment_status: string fulfillment_config_id: number semi_auto_enabled: boolean semi_auto_platform: string connector_mode: string login_status: string binding_status: string execution_status: string }>( ` SELECT f.channel, f.status AS fulfillment_status, f.fulfillment_config_id, cfg.semi_auto_enabled, cfg.semi_auto_platform, task.connector_mode, task.login_status, task.binding_status, task.execution_status FROM semi_auto_tasks task JOIN fulfillments f ON f.id = task.fulfillment_id JOIN manual_fulfillment_configs cfg ON cfg.id = task.manual_fulfillment_config_id WHERE task.order_item_id = $1 `, [result.orderItemId], ) assert.equal(row.rows[0]?.channel, 'manual') assert.equal(row.rows[0]?.fulfillment_status, 'assigned') assert.equal(Number(row.rows[0]?.fulfillment_config_id), result.configId) assert.equal(row.rows[0]?.semi_auto_enabled, true) assert.equal(row.rows[0]?.semi_auto_platform, 'huya') assert.equal(row.rows[0]?.connector_mode, 'embedded') assert.equal(row.rows[0]?.login_status, 'waiting_login') assert.equal(row.rows[0]?.binding_status, 'not_started') assert.equal(row.rows[0]?.execution_status, 'waiting_customer') } finally { if (orderId > 0) await query('DELETE FROM orders WHERE id = $1', [orderId]) if (configId > 0) { await query('DELETE FROM manual_fulfillment_configs WHERE id = $1', [configId]) } delete process.env.ENABLE_DEV_MOCK await closeDb() } }, ) test( '开发 Mock 可显式选择内置 Mock Connector', { skip: !TEST_DATABASE_URL ? '未设置 SEMI_AUTO_TASK_TEST_DATABASE_URL' : false }, async () => { process.env.DATABASE_URL = TEST_DATABASE_URL process.env.ENABLE_DEV_MOCK = '1' const { closeDb, query } = await import('../../db/client.js') const { createSemiAutoMock } = await import('../dev-mock/dev-mock-service.js') const suffix = `${Date.now()}_${Math.floor(Math.random() * 1_000_000)}` const orderNo = `IT-MOCK-SA-MOCK-${suffix}` let orderId = 0 let configId = 0 try { const result = await createSemiAutoMock({ orderNo, skuName: `半自动Mock连接器-${suffix}`, quantity: 1, connectorMode: 'mock', }) orderId = result.orderId configId = result.configId assert.equal(result.connectorMode, 'mock') const row = await query<{ connector_mode: string }>( 'SELECT connector_mode FROM semi_auto_tasks WHERE id = $1', [result.taskId], ) assert.equal(row.rows[0]?.connector_mode, 'mock') } finally { if (orderId > 0) await query('DELETE FROM orders WHERE id = $1', [orderId]) if (configId > 0) { await query('DELETE FROM manual_fulfillment_configs WHERE id = $1', [configId]) } delete process.env.ENABLE_DEV_MOCK await closeDb() } }, )