Files
order_site/apps/backend/src/services/semi-auto/semi-auto-task.integration.test.ts
T
yml d178556f03 收敛半自动平台为登录获取 CK 流程
移除角色绑定对客户页、状态展示和完成门禁的阻塞;虎牙与微博统一保存并直接导出 CK;同步精简客服工作台、配置项和权威文档。
2026-11-14 01:13:00 +08:00

597 lines
22 KiB
TypeScript

/**
* 半自动任务真实 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()
}
},
)