gongxiangp2/lib/__tests__/external-auto-listing.test.ts

457 lines
16 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,
queueTaskDetailBarcodeImagesUpdatedNotification,
queueTaskDetailFullSetNotification,
processExternalNotificationDelivery,
TASK_DETAIL_BARCODE_IMAGES_UPDATED_EVENT_TYPE,
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(),
},
materialAssetBarcode: {
findMany: 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([]),
}),
)
;(prisma.materialAssetBarcode.findMany as jest.Mock).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, TASK_DETAIL_BARCODE_IMAGES_UPDATED_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, TASK_DETAIL_BARCODE_IMAGES_UPDATED_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, TASK_DETAIL_BARCODE_IMAGES_UPDATED_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, TASK_DETAIL_BARCODE_IMAGES_UPDATED_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, TASK_DETAIL_BARCODE_IMAGES_UPDATED_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, TASK_DETAIL_BARCODE_IMAGES_UPDATED_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[9])
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,
)
})
test('条码图片更新通知应展开成一条码一记录并附带 lookupUrl', async () => {
;(prisma.material.findFirst as jest.Mock).mockResolvedValue({
id: 'material-1',
taskNo: 'TASK202604220001',
productModel: 'XG025503',
categoryName: '旗舰店',
sceneName: '客厅',
materialType: '任务-详情',
authorName: '旧作者',
createdAt: new Date('2026-04-22T09:00:00.000Z'),
user: {
realName: '张三',
username: 'zhangsan',
},
assets: [
{
id: 'asset-sku-1',
fileUrl: 'https://s3.jiansun.vip/gongxiangpan/public-display/task-detail/material-1/sku/1.webp',
fileName: 'sku-1.webp',
sortOrder: 0,
skuCode: 'SKU-001',
createdAt: new Date('2026-04-22T09:00:00.000Z'),
updatedAt: new Date('2026-04-22T09:05:00.000Z'),
barcodes: [{ barcode: '690000000001' }, { barcode: '690000000002' }],
},
],
})
;(prisma.materialAssetBarcode.findMany as jest.Mock).mockResolvedValue([
{
barcode: '690000000001',
assetId: 'asset-sku-1',
asset: {
createdAt: new Date('2026-04-22T09:00:00.000Z'),
sortOrder: 0,
},
},
{
barcode: '690000000002',
assetId: 'asset-sku-1',
asset: {
createdAt: new Date('2026-04-22T09:00:00.000Z'),
sortOrder: 0,
},
},
])
;(prisma.$queryRawUnsafe as jest.Mock)
.mockResolvedValueOnce([
{
id: 'target-1',
name: '自动上架平台',
callbackUrl: 'https://auto.example.com/callback',
isActive: true,
authType: 'none',
authConfig: {},
eventTypes: [TASK_DETAIL_BARCODE_IMAGES_UPDATED_EVENT_TYPE],
createdAt: new Date('2026-04-22T10:00:00.000Z'),
updatedAt: new Date('2026-04-22T10:00:00.000Z'),
},
])
.mockResolvedValueOnce([{ id: 'delivery-barcode-1' }])
const createdIds = await queueTaskDetailBarcodeImagesUpdatedNotification('material-1', ['asset-sku-1'])
const insertCall = (prisma.$queryRawUnsafe as jest.Mock).mock.calls[1]
const payload = JSON.parse(insertCall[9])
expect(createdIds).toEqual(['delivery-barcode-1'])
expect(insertCall[3]).toBe(TASK_DETAIL_BARCODE_IMAGES_UPDATED_EVENT_TYPE)
expect(typeof insertCall[7]).toBe('string')
expect(insertCall[7]).toBe(payload.eventId)
expect(payload.items).toEqual([
{
materialId: 'material-1',
assetId: 'asset-sku-1',
skuCode: 'SKU-001',
barcode: '690000000001',
previewUrl: 'https://s3.jiansun.vip/gongxiangpan/public-display/task-detail/material-1/sku/1.webp',
fileName: 'sku-1.webp',
sortOrder: 0,
uploadedAt: '2026-04-22T09:00:00.000Z',
updatedAt: '2026-04-22T09:05:00.000Z',
uploaderName: '张三',
isLatest: true,
lookupUrl:
'https://sc.jiansun.vip/api/external/product-barcode-images?barcode=690000000001',
},
{
materialId: 'material-1',
assetId: 'asset-sku-1',
skuCode: 'SKU-001',
barcode: '690000000002',
previewUrl: 'https://s3.jiansun.vip/gongxiangpan/public-display/task-detail/material-1/sku/1.webp',
fileName: 'sku-1.webp',
sortOrder: 0,
uploadedAt: '2026-04-22T09:00:00.000Z',
updatedAt: '2026-04-22T09:05:00.000Z',
uploaderName: '张三',
isLatest: true,
lookupUrl:
'https://sc.jiansun.vip/api/external/product-barcode-images?barcode=690000000002',
},
])
})
})