/** @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', }, ]) }) })