补充后端核心链路自动化测试
This commit is contained in:
@@ -10,6 +10,7 @@
|
|||||||
"seed:dev-data": "node scripts/seed-dev-data.js",
|
"seed:dev-data": "node scripts/seed-dev-data.js",
|
||||||
"db:migrate": "node src/db/migrate.js",
|
"db:migrate": "node src/db/migrate.js",
|
||||||
"dev": "node --watch-path=src --watch-path=config --watch-preserve-output src/index.js",
|
"dev": "node --watch-path=src --watch-path=config --watch-preserve-output src/index.js",
|
||||||
|
"test": "node --test",
|
||||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||||
"start": "node src/index.js"
|
"start": "node src/index.js"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -6,20 +6,52 @@ import {
|
|||||||
import { nowIso } from '../../utils/time.js'
|
import { nowIso } from '../../utils/time.js'
|
||||||
|
|
||||||
export async function reserveInventoryForTask({ skuCode, taskId, credentialType = 'tencent_code', roleKey = 'primary_code' }) {
|
export async function reserveInventoryForTask({ skuCode, taskId, credentialType = 'tencent_code', roleKey = 'primary_code' }) {
|
||||||
|
return reserveInventoryForTaskWithDeps(
|
||||||
|
{ skuCode, taskId, credentialType, roleKey },
|
||||||
|
{
|
||||||
|
assignReservedInventoryItem,
|
||||||
|
findFirstAvailableInventoryItemBySkuCode,
|
||||||
|
nowIso,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reserveInventoryForTaskWithDeps(
|
||||||
|
{ skuCode, taskId, credentialType = 'tencent_code', roleKey = 'primary_code' },
|
||||||
|
{
|
||||||
|
assignReservedInventoryItem: assignReserved = assignReservedInventoryItem,
|
||||||
|
findFirstAvailableInventoryItemBySkuCode: findAvailable = findFirstAvailableInventoryItemBySkuCode,
|
||||||
|
nowIso: getNowIso = nowIso,
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
if (!skuCode) {
|
if (!skuCode) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const available = await findFirstAvailableInventoryItemBySkuCode(skuCode, credentialType)
|
const available = await findAvailable(skuCode, credentialType)
|
||||||
|
|
||||||
if (!available) {
|
if (!available) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
return assignReservedInventoryItem(available.id, taskId, nowIso(), roleKey)
|
return assignReserved(available.id, taskId, getNowIso(), roleKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function releaseReservedInventoryItems(inventoryItemIds = [], updatedAt = nowIso()) {
|
export async function releaseReservedInventoryItems(inventoryItemIds = [], updatedAt = nowIso()) {
|
||||||
|
return releaseReservedInventoryItemsWithDeps(
|
||||||
|
inventoryItemIds,
|
||||||
|
updatedAt,
|
||||||
|
{ releaseReservedInventoryItem },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function releaseReservedInventoryItemsWithDeps(
|
||||||
|
inventoryItemIds = [],
|
||||||
|
updatedAt = nowIso(),
|
||||||
|
{
|
||||||
|
releaseReservedInventoryItem: releaseReserved = releaseReservedInventoryItem,
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
const released = []
|
const released = []
|
||||||
|
|
||||||
for (const inventoryItemId of inventoryItemIds) {
|
for (const inventoryItemId of inventoryItemIds) {
|
||||||
@@ -27,7 +59,7 @@ export async function releaseReservedInventoryItems(inventoryItemIds = [], updat
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
const item = await releaseReservedInventoryItem(inventoryItemId, updatedAt)
|
const item = await releaseReserved(inventoryItemId, updatedAt)
|
||||||
if (item) {
|
if (item) {
|
||||||
released.push(item)
|
released.push(item)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
|
||||||
|
import {
|
||||||
|
releaseReservedInventoryItemsWithDeps,
|
||||||
|
reserveInventoryForTaskWithDeps,
|
||||||
|
} from './inventory-service.js'
|
||||||
|
|
||||||
|
test('reserveInventoryForTaskWithDeps returns null when skuCode is missing', async () => {
|
||||||
|
let findCalled = false
|
||||||
|
|
||||||
|
const result = await reserveInventoryForTaskWithDeps(
|
||||||
|
{ skuCode: '', taskId: 12 },
|
||||||
|
{
|
||||||
|
findFirstAvailableInventoryItemBySkuCode: async () => {
|
||||||
|
findCalled = true
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.equal(result, null)
|
||||||
|
assert.equal(findCalled, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('reserveInventoryForTaskWithDeps reserves first available inventory item with defaults', async () => {
|
||||||
|
const calls = []
|
||||||
|
const result = await reserveInventoryForTaskWithDeps(
|
||||||
|
{ skuCode: 'dnf-cdk-a', taskId: 88 },
|
||||||
|
{
|
||||||
|
findFirstAvailableInventoryItemBySkuCode: async (skuCode, credentialType) => {
|
||||||
|
calls.push(['find', skuCode, credentialType])
|
||||||
|
return { id: 321 }
|
||||||
|
},
|
||||||
|
assignReservedInventoryItem: async (inventoryItemId, taskId, updatedAt, roleKey) => {
|
||||||
|
calls.push(['assign', inventoryItemId, taskId, updatedAt, roleKey])
|
||||||
|
return { id: inventoryItemId, reserved_by_task_id: taskId, role_key: roleKey, updated_at: updatedAt }
|
||||||
|
},
|
||||||
|
nowIso: () => '2026-04-14T12:00:00.000Z',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.deepEqual(calls, [
|
||||||
|
['find', 'dnf-cdk-a', 'tencent_code'],
|
||||||
|
['assign', 321, 88, '2026-04-14T12:00:00.000Z', 'primary_code'],
|
||||||
|
])
|
||||||
|
assert.equal(result?.id, 321)
|
||||||
|
assert.equal(result?.reserved_by_task_id, 88)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('releaseReservedInventoryItemsWithDeps skips empty ids and keeps successful releases only', async () => {
|
||||||
|
const calls = []
|
||||||
|
const result = await releaseReservedInventoryItemsWithDeps(
|
||||||
|
[0, null, 11, 12, '', 13],
|
||||||
|
'2026-04-14T13:00:00.000Z',
|
||||||
|
{
|
||||||
|
releaseReservedInventoryItem: async (inventoryItemId, updatedAt) => {
|
||||||
|
calls.push([inventoryItemId, updatedAt])
|
||||||
|
return inventoryItemId === 12 ? null : { id: inventoryItemId }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.deepEqual(calls, [
|
||||||
|
[11, '2026-04-14T13:00:00.000Z'],
|
||||||
|
[12, '2026-04-14T13:00:00.000Z'],
|
||||||
|
[13, '2026-04-14T13:00:00.000Z'],
|
||||||
|
])
|
||||||
|
assert.deepEqual(result, [
|
||||||
|
{ id: 11 },
|
||||||
|
{ id: 13 },
|
||||||
|
])
|
||||||
|
})
|
||||||
@@ -60,7 +60,7 @@ export async function replayAgisoTradeWebhookEvent(webhookEvent) {
|
|||||||
return executeAgisoTradeWebhook(parsed, webhookEvent.id, { requestId: requestLike.requestId })
|
return executeAgisoTradeWebhook(parsed, webhookEvent.id, { requestId: requestLike.requestId })
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildWebhookEventInput(requestLike, parsed) {
|
export function buildWebhookEventInput(requestLike, parsed) {
|
||||||
return {
|
return {
|
||||||
provider: parsed.provider,
|
provider: parsed.provider,
|
||||||
platform: parsed.platform,
|
platform: parsed.platform,
|
||||||
@@ -258,7 +258,7 @@ async function enrichTradeBeforeUpsert(parsed, { requestId = '', webhookEventId
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function shouldEnrichAgisoXianyuTrade(parsed) {
|
export function shouldEnrichAgisoXianyuTrade(parsed) {
|
||||||
if (!parsed || parsed.provider !== 'agiso' || parsed.platform !== 'xianyu') {
|
if (!parsed || parsed.provider !== 'agiso' || parsed.platform !== 'xianyu') {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -306,7 +306,7 @@ function hasDetailedOrderItems(items) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseAgisoTradeRequest(requestLike) {
|
export function parseAgisoTradeRequest(requestLike) {
|
||||||
const query = normalizeRecord(requestLike.query)
|
const query = normalizeRecord(requestLike.query)
|
||||||
const body = normalizeRecord(requestLike.body)
|
const body = normalizeRecord(requestLike.body)
|
||||||
const rawJson = String(body.json || '').trim()
|
const rawJson = String(body.json || '').trim()
|
||||||
@@ -386,10 +386,10 @@ function parseAgisoTradeRequest(requestLike) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function verifyAgisoSignature({ rawJson, timestamp, sign }) {
|
export function verifyAgisoSignatureWithSecret({ rawJson, timestamp, sign, appSecret }) {
|
||||||
const appSecret = String(runtimeConfig.platforms.agiso.appSecret || '').trim()
|
const normalizedSecret = String(appSecret || '').trim()
|
||||||
|
|
||||||
if (!appSecret) {
|
if (!normalizedSecret) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -399,7 +399,7 @@ function verifyAgisoSignature({ rawJson, timestamp, sign }) {
|
|||||||
|
|
||||||
const documentedDigest = crypto
|
const documentedDigest = crypto
|
||||||
.createHash('md5')
|
.createHash('md5')
|
||||||
.update(`${appSecret}json${rawJson}timestamp${timestamp}${appSecret}`, 'utf8')
|
.update(`${normalizedSecret}json${rawJson}timestamp${timestamp}${normalizedSecret}`, 'utf8')
|
||||||
.digest('hex')
|
.digest('hex')
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
|
|
||||||
@@ -409,13 +409,18 @@ function verifyAgisoSignature({ rawJson, timestamp, sign }) {
|
|||||||
|
|
||||||
const legacyDigest = crypto
|
const legacyDigest = crypto
|
||||||
.createHash('md5')
|
.createHash('md5')
|
||||||
.update(`${appSecret}${rawJson}${timestamp}`, 'utf8')
|
.update(`${normalizedSecret}${rawJson}${timestamp}`, 'utf8')
|
||||||
.digest('hex')
|
.digest('hex')
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
|
|
||||||
return legacyDigest === sign
|
return legacyDigest === sign
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function verifyAgisoSignature({ rawJson, timestamp, sign }) {
|
||||||
|
const appSecret = String(runtimeConfig.platforms.agiso.appSecret || '').trim()
|
||||||
|
return verifyAgisoSignatureWithSecret({ rawJson, timestamp, sign, appSecret })
|
||||||
|
}
|
||||||
|
|
||||||
function resolveEventType(aopic, payload) {
|
function resolveEventType(aopic, payload) {
|
||||||
const normalized = String(aopic || '').trim()
|
const normalized = String(aopic || '').trim()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import crypto from 'node:crypto'
|
||||||
|
|
||||||
|
import { runtimeConfig } from '../../config/runtime.js'
|
||||||
|
import {
|
||||||
|
parseAgisoTradeRequest,
|
||||||
|
shouldEnrichAgisoXianyuTrade,
|
||||||
|
verifyAgisoSignatureWithSecret,
|
||||||
|
} from './webhook-service.js'
|
||||||
|
|
||||||
|
test('parseAgisoTradeRequest maps Agiso payment_success webhook into stable order fields', () => {
|
||||||
|
const rawJson = '{"biz_order_id":4502259793206016214,"seller_id":"shop-9","seller_nick":"咸鱼店铺","buyer_id":"buyer-1","buyer_name":"测试买家","pay_time":"2026-04-14 20:30:40","total_fee":"25.50","currency":"CNY","items":[{"item_id":978102431355,"goods_name":"DNF礼包","quantity":2,"sku":"dnf-cdk-a|商品名称:DNF礼包"}]}'
|
||||||
|
const timestamp = '1713097840'
|
||||||
|
const appSecret = String(runtimeConfig.platforms?.agiso?.appSecret || '').trim()
|
||||||
|
const sign = appSecret
|
||||||
|
? crypto.createHash('md5').update(`${appSecret}json${rawJson}timestamp${timestamp}${appSecret}`, 'utf8').digest('hex')
|
||||||
|
: ''
|
||||||
|
|
||||||
|
const parsed = parseAgisoTradeRequest({
|
||||||
|
query: {
|
||||||
|
aopic: '1',
|
||||||
|
timestamp,
|
||||||
|
sign,
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
json: rawJson,
|
||||||
|
fromPlatform: 'xianyu',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.equal(parsed.provider, 'agiso')
|
||||||
|
assert.equal(parsed.platform, 'xianyu')
|
||||||
|
assert.equal(parsed.eventType, 'payment_success')
|
||||||
|
assert.equal(parsed.signatureValid, true)
|
||||||
|
assert.equal(parsed.platformOrderId, '4502259793206016214')
|
||||||
|
assert.equal(parsed.shopId, 'shop-9')
|
||||||
|
assert.equal(parsed.shopName, '咸鱼店铺')
|
||||||
|
assert.equal(parsed.payStatus, 'paid')
|
||||||
|
assert.equal(parsed.orderStatus, 'paid')
|
||||||
|
assert.equal(parsed.totalAmount, 2550)
|
||||||
|
assert.equal(parsed.items.length, 1)
|
||||||
|
assert.equal(parsed.items[0]?.skuCode, 'dnf-cdk-a')
|
||||||
|
assert.equal(parsed.items[0]?.quantity, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('shouldEnrichAgisoXianyuTrade only enriches when xianyu trade fields are incomplete', () => {
|
||||||
|
assert.equal(shouldEnrichAgisoXianyuTrade({
|
||||||
|
provider: 'agiso',
|
||||||
|
platform: 'xianyu',
|
||||||
|
totalAmount: 0,
|
||||||
|
buyerName: '',
|
||||||
|
shopName: '',
|
||||||
|
items: [],
|
||||||
|
}), true)
|
||||||
|
|
||||||
|
assert.equal(shouldEnrichAgisoXianyuTrade({
|
||||||
|
provider: 'agiso',
|
||||||
|
platform: 'xianyu',
|
||||||
|
totalAmount: 2550,
|
||||||
|
buyerName: '测试买家',
|
||||||
|
shopName: '咸鱼店铺',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
skuCode: 'dnf-cdk-a',
|
||||||
|
spec: {
|
||||||
|
title: 'DNF礼包',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}), false)
|
||||||
|
|
||||||
|
assert.equal(shouldEnrichAgisoXianyuTrade({
|
||||||
|
provider: 'agiso',
|
||||||
|
platform: 'taobao',
|
||||||
|
totalAmount: 0,
|
||||||
|
items: [],
|
||||||
|
}), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('verifyAgisoSignatureWithSecret supports documented and legacy signatures', () => {
|
||||||
|
const appSecret = 'unit-test-secret'
|
||||||
|
const rawJson = '{"biz_order_id":"4502259793206016214"}'
|
||||||
|
const timestamp = '1713097840'
|
||||||
|
const documentedSign = crypto
|
||||||
|
.createHash('md5')
|
||||||
|
.update(`${appSecret}json${rawJson}timestamp${timestamp}${appSecret}`, 'utf8')
|
||||||
|
.digest('hex')
|
||||||
|
const legacySign = crypto
|
||||||
|
.createHash('md5')
|
||||||
|
.update(`${appSecret}${rawJson}${timestamp}`, 'utf8')
|
||||||
|
.digest('hex')
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
verifyAgisoSignatureWithSecret({ rawJson, timestamp, sign: documentedSign, appSecret }),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
assert.equal(
|
||||||
|
verifyAgisoSignatureWithSecret({ rawJson, timestamp, sign: legacySign, appSecret }),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
assert.equal(
|
||||||
|
verifyAgisoSignatureWithSecret({ rawJson, timestamp, sign: 'bad-sign', appSecret }),
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -262,7 +262,7 @@ function generateSign(params, appSecret) {
|
|||||||
return crypto.createHash('md5').update(raw, 'utf8').digest('hex').toLowerCase()
|
return crypto.createHash('md5').update(raw, 'utf8').digest('hex').toLowerCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
function isAgisoAutoDeliverySuccess(statusCode, payload) {
|
export function isAgisoAutoDeliverySuccess(statusCode, payload) {
|
||||||
if (statusCode < 200 || statusCode >= 300) {
|
if (statusCode < 200 || statusCode >= 300) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -286,7 +286,7 @@ function isAgisoAutoDeliverySuccess(statusCode, payload) {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveAgisoAutoDeliveryErrorMessage(payload, rawText, statusCode) {
|
export function resolveAgisoAutoDeliveryErrorMessage(payload, rawText, statusCode) {
|
||||||
if (payload && typeof payload === 'object') {
|
if (payload && typeof payload === 'object') {
|
||||||
for (const value of [payload.Error_Msg, payload.msg, payload.message, payload.error]) {
|
for (const value of [payload.Error_Msg, payload.msg, payload.message, payload.error]) {
|
||||||
const normalized = String(value || '').trim()
|
const normalized = String(value || '').trim()
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import assert from 'node:assert/strict'
|
|||||||
import {
|
import {
|
||||||
hasAgisoAutoDeliverySucceeded,
|
hasAgisoAutoDeliverySucceeded,
|
||||||
isOrderReadyForAgisoAutoDelivery,
|
isOrderReadyForAgisoAutoDelivery,
|
||||||
|
isAgisoAutoDeliverySuccess,
|
||||||
|
resolveAgisoAutoDeliveryErrorMessage,
|
||||||
} from './auto-delivery-service.js'
|
} from './auto-delivery-service.js'
|
||||||
|
|
||||||
test('isOrderReadyForAgisoAutoDelivery requires every task to be delivered', () => {
|
test('isOrderReadyForAgisoAutoDelivery requires every task to be delivered', () => {
|
||||||
@@ -28,3 +30,31 @@ test('hasAgisoAutoDeliverySucceeded detects successful context on any task', ()
|
|||||||
{ context_json: { agisoAutoDelivery: { status: 'failed' } } },
|
{ context_json: { agisoAutoDelivery: { status: 'failed' } } },
|
||||||
]), false)
|
]), false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('isAgisoAutoDeliverySuccess accepts empty successful payloads and explicit success codes', () => {
|
||||||
|
assert.equal(isAgisoAutoDeliverySuccess(200, {}), true)
|
||||||
|
assert.equal(isAgisoAutoDeliverySuccess(200, { IsSuccess: true }), true)
|
||||||
|
assert.equal(isAgisoAutoDeliverySuccess(200, { Error_Code: 0 }), true)
|
||||||
|
assert.equal(isAgisoAutoDeliverySuccess(500, { IsSuccess: true }), false)
|
||||||
|
assert.equal(isAgisoAutoDeliverySuccess(200, { Error_Code: 500 }), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('resolveAgisoAutoDeliveryErrorMessage prefers structured payload message before raw text', () => {
|
||||||
|
assert.equal(
|
||||||
|
resolveAgisoAutoDeliveryErrorMessage(
|
||||||
|
{ Error_Msg: '库存不足' },
|
||||||
|
'raw body',
|
||||||
|
400,
|
||||||
|
),
|
||||||
|
'库存不足',
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
resolveAgisoAutoDeliveryErrorMessage(
|
||||||
|
{},
|
||||||
|
'fallback raw body',
|
||||||
|
400,
|
||||||
|
),
|
||||||
|
'fallback raw body',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|||||||
@@ -19,9 +19,11 @@
|
|||||||
"src/repositories/task-inventory-binding-repo.js",
|
"src/repositories/task-inventory-binding-repo.js",
|
||||||
"src/repositories/task-repo.js",
|
"src/repositories/task-repo.js",
|
||||||
"src/repositories/webhook-event-repo.js",
|
"src/repositories/webhook-event-repo.js",
|
||||||
"src/services/admin/admin-read-helpers.js",
|
"src/services/admin/admin-inventory-read-helpers.js",
|
||||||
|
"src/services/admin/admin-order-read-helpers.js",
|
||||||
"src/services/admin/admin-read-shared-helpers.js",
|
"src/services/admin/admin-read-shared-helpers.js",
|
||||||
"src/services/admin/admin-task-read-helpers.js",
|
"src/services/admin/admin-task-read-helpers.js",
|
||||||
|
"src/services/admin/admin-webhook-read-helpers.js",
|
||||||
"src/services/admin/admin-dashboard-service.js",
|
"src/services/admin/admin-dashboard-service.js",
|
||||||
"src/services/admin/admin-message-delivery-service.js",
|
"src/services/admin/admin-message-delivery-service.js",
|
||||||
"src/services/admin/admin-platform-config-service.js",
|
"src/services/admin/admin-platform-config-service.js",
|
||||||
|
|||||||
Reference in New Issue
Block a user