gongxiangp2/lib/__tests__/external-auto-listing.test.ts
chenpei 02ebaabd0e feat(外部接口): 新增自动上架成套通知能力
- 新增任务-详情全套入库后的自动上架通知与重试补发能力

- 扩展 product-assets 支持按 materialId 拉取正式资源

- 补充后台通知目标管理、投递记录和对接文档
2026-03-25 20:39:56 +08:00

348 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/** @jest-environment node */
import {
AUTO_LISTING_DELIVERY_STATUS,
AUTO_LISTING_EVENT_TYPE,
createExternalNotificationTarget,
listExternalNotificationDeliveries,
listExternalNotificationTargets,
queueTaskDetailFullSetNotification,
processExternalNotificationDelivery,
retryExternalNotificationDelivery,
updateExternalNotificationTarget,
} from '@/lib/external-auto-listing'
import { prisma } from '@/lib/prisma'
jest.mock('@/lib/prisma', () => ({
prisma: {
$executeRawUnsafe: jest.fn(),
$queryRawUnsafe: jest.fn(),
$transaction: jest.fn(),
material: {
findFirst: jest.fn(),
},
},
}))
jest.mock('@/lib/public-app-url', () => ({
getPublicAppOrigin: jest.fn(() => 'https://sc.jiansun.vip'),
}))
describe('external auto listing', () => {
beforeEach(() => {
jest.clearAllMocks()
jest.useFakeTimers()
;(prisma.$executeRawUnsafe as jest.Mock).mockResolvedValue(undefined)
;(prisma.$queryRawUnsafe as jest.Mock).mockResolvedValue([])
;(prisma.$transaction as jest.Mock).mockImplementation(async (callback: any) =>
callback({
$executeRawUnsafe: jest.fn().mockResolvedValue(undefined),
$queryRawUnsafe: jest.fn().mockResolvedValue([]),
}),
)
})
afterEach(() => {
jest.useRealTimers()
})
test('应能创建并更新自动上架通知目标', async () => {
;(prisma.$queryRawUnsafe as jest.Mock)
.mockResolvedValueOnce([
{
id: 'target-1',
name: '自动上架平台',
callbackUrl: 'https://auto.example.com/callback',
isActive: true,
authType: 'bearer',
authConfig: { token: 'secret-token' },
eventTypes: [AUTO_LISTING_EVENT_TYPE],
createdAt: new Date('2026-03-25T10:00:00.000Z'),
updatedAt: new Date('2026-03-25T10:00:00.000Z'),
},
])
.mockResolvedValueOnce([
{
id: 'target-1',
name: '自动上架平台',
callbackUrl: 'https://auto.example.com/callback',
isActive: true,
authType: 'bearer',
authConfig: { token: 'secret-token' },
eventTypes: [AUTO_LISTING_EVENT_TYPE],
createdAt: new Date('2026-03-25T10:00:00.000Z'),
updatedAt: new Date('2026-03-25T10:00:00.000Z'),
},
])
.mockResolvedValueOnce([
{
id: 'target-1',
name: '自动上架平台-新版',
callbackUrl: 'https://auto.example.com/callback',
isActive: false,
authType: 'header',
authConfig: { headerName: 'X-Signature', headerValue: 'abc123' },
eventTypes: [AUTO_LISTING_EVENT_TYPE],
createdAt: new Date('2026-03-25T10:00:00.000Z'),
updatedAt: new Date('2026-03-25T10:30:00.000Z'),
},
])
.mockResolvedValueOnce([
{
id: 'target-1',
name: '自动上架平台-新版',
callbackUrl: 'https://auto.example.com/callback',
isActive: false,
authType: 'header',
authConfig: { headerName: 'X-Signature', headerValue: 'abc123' },
eventTypes: [AUTO_LISTING_EVENT_TYPE],
createdAt: new Date('2026-03-25T10:00:00.000Z'),
updatedAt: new Date('2026-03-25T10:30:00.000Z'),
},
])
const created = await createExternalNotificationTarget({
name: '自动上架平台',
callbackUrl: 'https://auto.example.com/callback',
authType: 'bearer',
authConfig: { token: 'secret-token' },
})
expect(created).toEqual({
id: 'target-1',
name: '自动上架平台',
callbackUrl: 'https://auto.example.com/callback',
isActive: true,
authType: 'bearer',
authConfig: { token: 'secret-token' },
eventTypes: [AUTO_LISTING_EVENT_TYPE],
createdAt: '2026-03-25T10:00:00.000Z',
updatedAt: '2026-03-25T10:00:00.000Z',
})
const listed = await listExternalNotificationTargets()
expect(listed).toHaveLength(1)
const updated = await updateExternalNotificationTarget('target-1', {
name: '自动上架平台-新版',
callbackUrl: 'https://auto.example.com/callback',
isActive: false,
authType: 'header',
authConfig: {
headerName: 'X-Signature',
headerValue: 'abc123',
},
})
expect(updated).toEqual({
id: 'target-1',
name: '自动上架平台-新版',
callbackUrl: 'https://auto.example.com/callback',
isActive: false,
authType: 'header',
authConfig: { headerName: 'X-Signature', headerValue: 'abc123' },
eventTypes: [AUTO_LISTING_EVENT_TYPE],
createdAt: '2026-03-25T10:00:00.000Z',
updatedAt: '2026-03-25T10:30:00.000Z',
})
})
test('投递成功后应标记为 succeeded', async () => {
;(prisma.$transaction as jest.Mock).mockImplementation(async (callback: any) =>
callback({
$executeRawUnsafe: jest.fn().mockResolvedValue(undefined),
$queryRawUnsafe: jest.fn().mockResolvedValue([
{
id: 'delivery-1',
status: 'pending',
payload: { eventId: 'evt-1' },
attemptCount: 0,
maxAttempts: 5,
updatedAt: new Date('2026-03-25T10:00:00.000Z'),
targetId: 'target-1',
name: '自动上架平台',
callbackUrl: 'https://auto.example.com/callback',
isActive: true,
authType: 'bearer',
authConfig: { token: 'secret-token' },
eventTypes: [AUTO_LISTING_EVENT_TYPE],
createdAt: new Date('2026-03-25T10:00:00.000Z'),
targetUpdatedAt: new Date('2026-03-25T10:00:00.000Z'),
},
]),
}),
)
global.fetch = jest.fn(async () => new Response('ok', { status: 200 })) as any
const result = await processExternalNotificationDelivery('delivery-1')
expect(result).toEqual({
success: true,
status: AUTO_LISTING_DELIVERY_STATUS.SUCCEEDED,
})
expect(prisma.$executeRawUnsafe).toHaveBeenCalledWith(
expect.stringContaining(`UPDATE "${'external_notification_deliveries'}"`),
'delivery-1',
AUTO_LISTING_DELIVERY_STATUS.SUCCEEDED,
200,
'ok',
)
})
test('创建通知任务时应把预览图和拉取地址写入 payload', async () => {
;(prisma.material.findFirst as jest.Mock).mockResolvedValue({
id: 'material-1',
taskNo: null,
productModel: 'XG025503',
categoryName: '旗舰店',
sceneName: '客厅',
materialType: '任务-详情',
previewUrl: 'https://s3.jiansun.vip/gongxiangpan/public-display/task-detail/material-1/cover.webp',
isFullSet: true,
createdAt: new Date('2026-03-25T08:00:00.000Z'),
assets: [
{
id: 'asset-main-1',
assetType: 'main-preview',
fileUrl: 'https://s3.jiansun.vip/gongxiangpan/public-display/task-detail/material-1/main/1.webp',
fileName: 'main-1.webp',
sortOrder: 0,
},
{
id: 'asset-sku-1',
assetType: 'sku-preview',
fileUrl: 'https://s3.jiansun.vip/gongxiangpan/public-display/task-detail/material-1/sku/1.webp',
fileName: 'sku-1.webp',
sortOrder: 1,
},
{
id: 'asset-detail-1',
assetType: 'detail-preview',
fileUrl: 'https://s3.jiansun.vip/gongxiangpan/public-display/task-detail/material-1/detail/1.webp',
fileName: 'detail-1.webp',
sortOrder: 2,
},
],
})
;(prisma.$queryRawUnsafe as jest.Mock)
.mockResolvedValueOnce([
{
id: 'target-1',
name: '自动上架平台',
callbackUrl: 'https://auto.example.com/callback',
isActive: true,
authType: 'none',
authConfig: {},
eventTypes: [AUTO_LISTING_EVENT_TYPE],
createdAt: new Date('2026-03-25T10:00:00.000Z'),
updatedAt: new Date('2026-03-25T10:00:00.000Z'),
},
])
.mockResolvedValueOnce([{ id: 'delivery-1' }])
const createdIds = await queueTaskDetailFullSetNotification('material-1')
const insertCall = (prisma.$queryRawUnsafe as jest.Mock).mock.calls[1]
const payload = JSON.parse(insertCall[8])
expect(createdIds).toEqual(['delivery-1'])
expect(payload.pullUrl).toBe(
'https://sc.jiansun.vip/api/external/product-assets?materialId=material-1',
)
expect(payload.coverPreviewUrl).toBe(
'https://s3.jiansun.vip/gongxiangpan/public-display/task-detail/material-1/cover.webp',
)
expect(payload.previewAssets).toEqual({
main: [
{
id: 'asset-main-1',
url: 'https://s3.jiansun.vip/gongxiangpan/public-display/task-detail/material-1/main/1.webp',
fileName: 'main-1.webp',
sortOrder: 0,
},
],
sku: [
{
id: 'asset-sku-1',
url: 'https://s3.jiansun.vip/gongxiangpan/public-display/task-detail/material-1/sku/1.webp',
fileName: 'sku-1.webp',
sortOrder: 1,
},
],
detail: [
{
id: 'asset-detail-1',
url: 'https://s3.jiansun.vip/gongxiangpan/public-display/task-detail/material-1/detail/1.webp',
fileName: 'detail-1.webp',
sortOrder: 2,
},
],
})
})
test('投递失败后应进入 retrying,并支持手工补发查询', async () => {
;(prisma.$transaction as jest.Mock).mockImplementation(async (callback: any) =>
callback({
$executeRawUnsafe: jest.fn().mockResolvedValue(undefined),
$queryRawUnsafe: jest.fn().mockResolvedValue([
{
id: 'delivery-2',
status: 'pending',
payload: { eventId: 'evt-2' },
attemptCount: 0,
maxAttempts: 5,
updatedAt: new Date('2026-03-25T10:00:00.000Z'),
targetId: 'target-1',
name: '自动上架平台',
callbackUrl: 'https://auto.example.com/callback',
isActive: true,
authType: 'none',
authConfig: {},
eventTypes: [AUTO_LISTING_EVENT_TYPE],
createdAt: new Date('2026-03-25T10:00:00.000Z'),
targetUpdatedAt: new Date('2026-03-25T10:00:00.000Z'),
},
]),
}),
)
;(prisma.$queryRawUnsafe as jest.Mock)
.mockResolvedValueOnce([
{
id: 'delivery-2',
targetId: 'target-1',
targetName: '自动上架平台',
eventType: AUTO_LISTING_EVENT_TYPE,
materialId: 'material-1',
materialType: '任务-详情',
taskNo: null,
status: AUTO_LISTING_DELIVERY_STATUS.RETRYING,
attemptCount: 1,
maxAttempts: 5,
nextRetryAt: new Date('2026-03-25T10:01:00.000Z'),
lastStatusCode: 500,
lastResponseBody: 'server error',
lastError: 'HTTP 500',
createdAt: new Date('2026-03-25T10:00:00.000Z'),
updatedAt: new Date('2026-03-25T10:00:01.000Z'),
completedAt: null,
},
])
global.fetch = jest.fn(async () => new Response('server error', { status: 500 })) as any
const result = await processExternalNotificationDelivery('delivery-2')
const deliveries = await listExternalNotificationDeliveries()
await retryExternalNotificationDelivery('delivery-2')
expect(result).toEqual({
success: false,
status: AUTO_LISTING_DELIVERY_STATUS.RETRYING,
})
expect(deliveries).toHaveLength(1)
expect(deliveries[0].status).toBe(AUTO_LISTING_DELIVERY_STATUS.RETRYING)
expect(prisma.$executeRawUnsafe).toHaveBeenCalledWith(
expect.stringContaining(`UPDATE "${'external_notification_deliveries'}"`),
'delivery-2',
AUTO_LISTING_DELIVERY_STATUS.PENDING,
)
})
})