/** @jest-environment node */ import mssql from 'mssql' import { prisma } from '@/lib/prisma' import { nextPerformanceConversionSyncDelay, syncPerformanceConversionMetrics } from '@/lib/performance-conversion' jest.mock('mssql', () => { const request = { input: jest.fn(), query: jest.fn() } request.input.mockReturnValue(request) const pool = { connect: jest.fn(), close: jest.fn(), request: jest.fn(() => request) } return { __esModule: true, default: { ConnectionPool: jest.fn(() => pool), NVarChar: jest.fn((length: number) => `NVarChar(${length})`), __pool: pool, __request: request, }, } }) jest.mock('@/lib/prisma', () => ({ prisma: { dingWorkflowSnapshot: { findMany: jest.fn() }, performanceConversionSyncState: { upsert: jest.fn(), updateMany: jest.fn(), findUniqueOrThrow: jest.fn(), }, performanceConversionMetricSnapshot: { findFirst: jest.fn() }, $transaction: jest.fn(), }, })) jest.mock('@/lib/ding-workflow-sync', () => ({ DING_WORKFLOW_ALLOWED_PROCESS_CODES: ['art-process'], DING_WORKFLOW_STARTED_AT_CUTOFF_ISO: '2026-03-31T16:00:00.000Z', getDingWorkflowSyncMetadata: jest.fn(), isDingWorkflowApprovedCompletion: jest.fn(() => true), })) const workflowFindMany = prisma.dingWorkflowSnapshot.findMany as jest.Mock const syncState = prisma.performanceConversionSyncState as unknown as { upsert: jest.Mock updateMany: jest.Mock findUniqueOrThrow: jest.Mock } const metricSnapshot = prisma.performanceConversionMetricSnapshot as unknown as { findFirst: jest.Mock } const runTransaction = prisma.$transaction as jest.Mock const transactionSnapshot = { findMany: jest.fn(), deleteMany: jest.fn(), createMany: jest.fn() } const transactionState = { updateMany: jest.fn() } const mssqlMock = mssql as typeof mssql & { __pool: { connect: jest.Mock; close: jest.Mock } __request: { input: jest.Mock; query: jest.Mock } } function completedWorkflow() { return { processInstanceId: 'flow-1', businessId: 'approval-1', title: '主图优化', result: 'agree', bizAction: 'finish', status: 'completed', processCode: 'art-process', brand: '亿家达', sourceFinishedAtRaw: '2026-09-01T08:00:00.000Z', formFields: { productId: '1084275176878', platform: '天猫' }, } } describe('performance conversion daily incremental sync', () => { beforeAll(() => { jest.useFakeTimers().setSystemTime(new Date('2026-09-18T04:00:00.000Z')) }) afterAll(() => { jest.useRealTimers() }) beforeEach(() => { jest.clearAllMocks() process.env.REPORT_DB_SERVER = 'report.internal' process.env.REPORT_DB_DATABASE = 'report' process.env.REPORT_DB_USER = 'reader' process.env.REPORT_DB_PASSWORD = 'secret' mssqlMock.__pool.connect.mockResolvedValue(undefined) mssqlMock.__pool.close.mockResolvedValue(undefined) syncState.upsert.mockResolvedValue({}) syncState.updateMany.mockResolvedValue({ count: 1 }) workflowFindMany.mockResolvedValue([completedWorkflow()]) runTransaction.mockImplementation(async (callback: (tx: unknown) => unknown) => callback({ performanceConversionMetricSnapshot: transactionSnapshot, performanceConversionSyncState: transactionState, })) transactionState.updateMany.mockResolvedValue({ count: 1 }) transactionSnapshot.findMany.mockResolvedValue([]) transactionSnapshot.deleteMany.mockResolvedValue({ count: 0 }) transactionSnapshot.createMany.mockResolvedValue({ count: 1 }) }) it('backfills the full workflow monitoring window on the first product sync', async () => { syncState.findUniqueOrThrow.mockResolvedValue({ sourceCursorRaw: null }) metricSnapshot.findFirst.mockResolvedValue(null) mssqlMock.__request.query.mockResolvedValue({ recordset: [{ sourceRowId: 'row-1', metricDate: new Date('2026-09-17T00:00:00.000Z'), productId: '1084275176878', paymentAmount: '100.00', }], }) await expect(syncPerformanceConversionMetrics()).resolves.toEqual({ skipped: false, rowsUpserted: 1, productsSynced: 1, }) expect(mssqlMock.__request.input).toHaveBeenCalledWith('startDate', 'NVarChar(10)', '2026-09-01') expect(mssqlMock.__request.input).toHaveBeenCalledWith('endDate', 'NVarChar(10)', '2026-09-18') expect(transactionSnapshot.deleteMany).toHaveBeenCalledWith({ where: { productId: '1084275176878', NOT: { sourceKey: { startsWith: 'tmall-store-v1:' } }, metricDate: { gte: new Date('2026-09-01T00:00:00.000Z'), lte: new Date('2026-09-18T00:00:00.000Z'), }, }, }) expect(transactionSnapshot.createMany).toHaveBeenCalledWith({ data: [expect.objectContaining({ sourceKey: '1084275176878:2026-09-17:row-1', productId: '1084275176878', paymentAmount: '100.00', })], }) }) it('refreshes only the rolling correction window after a completed sync', async () => { syncState.findUniqueOrThrow.mockResolvedValue({ sourceCursorRaw: JSON.stringify({ version: 1, lastSourceDate: '2026-09-17' }), }) metricSnapshot.findFirst.mockResolvedValue({ id: 'existing-row' }) mssqlMock.__request.query.mockResolvedValue({ recordset: [] }) await syncPerformanceConversionMetrics() expect(mssqlMock.__request.input).toHaveBeenCalledWith('startDate', 'NVarChar(10)', '2026-09-15') expect(mssqlMock.__request.input).toHaveBeenCalledWith('endDate', 'NVarChar(10)', '2026-09-18') expect(transactionSnapshot.createMany).not.toHaveBeenCalled() }) it('does not report success after ownership of the sync lease is lost', async () => { syncState.findUniqueOrThrow.mockResolvedValue({ sourceCursorRaw: null }) metricSnapshot.findFirst.mockResolvedValue(null) mssqlMock.__request.query.mockResolvedValue({ recordset: [] }) syncState.updateMany .mockResolvedValueOnce({ count: 1 }) .mockResolvedValueOnce({ count: 1 }) .mockResolvedValueOnce({ count: 0 }) .mockResolvedValueOnce({ count: 0 }) await expect(syncPerformanceConversionMetrics()).rejects.toThrow('经营数据同步租约已失效') }) function storeRanges(firstDate = '2026-09-01', lastDate = '2026-09-17', roiLastDate = lastDate) { return { recordset: ['overall', 'roi'].map((sourceKind) => ({ sourceKind, sourceStore: '朴美家具旗舰店', firstDate, lastDate: sourceKind === 'overall' ? lastDate : roiLastDate, })), } } function storeWorkflow() { return { ...completedWorkflow(), brand: '朴美' } } function storeRow(overrides = {}) { return { sourceRowId: 'row-1', metricDate: '2026-09-17', productId: '1084275176878', sourceBrand: '朴美家具旗舰店', paymentAmount: '100', visitors: '10', promotionSourceRowId: 'roi-1', impressions: '1234', roi: '2.5', ...overrides, } } it('backfills the new source even when the old source has already synced this product', async () => { workflowFindMany.mockResolvedValue([storeWorkflow()]) syncState.findUniqueOrThrow.mockResolvedValue({ sourceCursorRaw: JSON.stringify({ version: 1, lastSourceDate: '2026-09-17' }) }) metricSnapshot.findFirst.mockResolvedValue(null) mssqlMock.__request.query.mockResolvedValueOnce(storeRanges()).mockResolvedValueOnce({ recordset: [storeRow()] }) await syncPerformanceConversionMetrics() expect(mssqlMock.__request.input).toHaveBeenCalledWith('startDate', 'NVarChar(10)', '2026-09-01') expect(metricSnapshot.findFirst).toHaveBeenCalledWith(expect.objectContaining({ where: { productId: '1084275176878', sourceKey: { startsWith: 'tmall-store-v1:朴美:' }, } })) expect(transactionSnapshot.createMany).toHaveBeenCalledWith({ data: [expect.objectContaining({ sourceKey: 'tmall-store-v1:朴美:1084275176878:2026-09-17', impressions: '1234', roi: '2.5', })] }) const completion = syncState.updateMany.mock.calls.find(([args]) => args.data.status === 'ready')[0] expect(JSON.parse(completion.data.sourceCursorRaw)).toMatchObject({ version: 2, tmallStoreDates: { 朴美: '2026-09-17' } }) }) it('clips replacement to the retained monthly-view range and preserves earlier cached history', async () => { workflowFindMany.mockResolvedValue([storeWorkflow()]) syncState.findUniqueOrThrow.mockResolvedValue({ sourceCursorRaw: null }) metricSnapshot.findFirst.mockResolvedValue({ id: 'existing-store-snapshot' }) mssqlMock.__request.query.mockResolvedValueOnce(storeRanges('2026-09-10')).mockResolvedValueOnce({ recordset: [] }) await syncPerformanceConversionMetrics() expect(transactionSnapshot.deleteMany).toHaveBeenCalledWith({ where: { productId: '1084275176878', sourceKey: { startsWith: 'tmall-store-v1:朴美:' }, metricDate: { gte: new Date('2026-09-10T00:00:00.000Z'), lte: new Date('2026-09-17T00:00:00.000Z') }, AND: { sourceKey: { in: [] } }, } }) }) it('uses the actual source cursor rather than the clock and waits for the delayed ROI source', async () => { workflowFindMany.mockResolvedValue([storeWorkflow()]) syncState.findUniqueOrThrow.mockResolvedValue({ sourceCursorRaw: JSON.stringify({ version: 2, lastSourceDate: '2026-09-17', tmallStoreDates: { 朴美: '2026-09-13' }, }) }) metricSnapshot.findFirst.mockResolvedValue({ id: 'existing' }) mssqlMock.__request.query.mockResolvedValueOnce(storeRanges('2026-09-01', '2026-09-17', '2026-09-14')).mockResolvedValueOnce({ recordset: [] }) await syncPerformanceConversionMetrics() expect(mssqlMock.__request.input).toHaveBeenCalledWith('startDate', 'NVarChar(10)', '2026-09-11') const completion = syncState.updateMany.mock.calls.find(([args]) => args.data.status === 'ready')[0] expect(JSON.parse(completion.data.sourceCursorRaw).tmallStoreDates.朴美).toBe('2026-09-14') }) it('only replaces returned day keys, retaining cached days absent from a monthly view', async () => { workflowFindMany.mockResolvedValue([storeWorkflow()]) syncState.findUniqueOrThrow.mockResolvedValue({ sourceCursorRaw: null }) metricSnapshot.findFirst.mockResolvedValue({ id: 'existing' }) mssqlMock.__request.query.mockResolvedValueOnce(storeRanges()).mockResolvedValueOnce({ recordset: [storeRow()] }) await syncPerformanceConversionMetrics() expect(transactionSnapshot.deleteMany).toHaveBeenCalledWith({ where: expect.objectContaining({ sourceKey: { startsWith: 'tmall-store-v1:朴美:' }, AND: { sourceKey: { in: ['tmall-store-v1:朴美:1084275176878:2026-09-17'] } }, }) }) }) it('excludes explicitly non-Tmall workflows of the three new brands from this source', async () => { workflowFindMany.mockResolvedValue([{ ...storeWorkflow(), formFields: { productId: '1084275176878', platform: '京东' } }]) syncState.findUniqueOrThrow.mockResolvedValue({ sourceCursorRaw: null }) await expect(syncPerformanceConversionMetrics()).resolves.toMatchObject({ productsSynced: 0, rowsUpserted: 0 }) expect(mssqlMock.__request.query).not.toHaveBeenCalled() expect(runTransaction).not.toHaveBeenCalled() }) it('does not purge history or advance a brand when the retained view has no rows', async () => { workflowFindMany.mockResolvedValue([storeWorkflow()]) syncState.findUniqueOrThrow.mockResolvedValue({ sourceCursorRaw: null }) metricSnapshot.findFirst.mockResolvedValue({ id: 'existing' }) mssqlMock.__request.query.mockResolvedValueOnce({ recordset: [] }) await expect(syncPerformanceConversionMetrics()).resolves.toMatchObject({ rowsUpserted: 0, productsSynced: 0 }) expect(runTransaction).not.toHaveBeenCalled() const completion = syncState.updateMany.mock.calls.find(([args]) => args.data.status === 'ready')[0] expect(JSON.parse(completion.data.sourceCursorRaw).tmallStoreDates).toEqual({}) }) it('keeps legacy and different-store windows independent even for the same product ID', async () => { workflowFindMany.mockResolvedValue([storeWorkflow(), completedWorkflow()]) syncState.findUniqueOrThrow.mockResolvedValue({ sourceCursorRaw: null }) metricSnapshot.findFirst.mockResolvedValue(null) mssqlMock.__request.query.mockResolvedValueOnce(storeRanges()).mockResolvedValue({ recordset: [] }) await expect(syncPerformanceConversionMetrics()).resolves.toMatchObject({ productsSynced: 2 }) expect(transactionSnapshot.deleteMany.mock.calls.map(([args]) => args.where)).toEqual([ expect.objectContaining({ sourceKey: { startsWith: 'tmall-store-v1:朴美:' } }), expect.objectContaining({ NOT: { sourceKey: { startsWith: 'tmall-store-v1:' } } }), ]) }) it('preserves cached supplemental fields only when the ROI row is absent, not an explicit zero', async () => { workflowFindMany.mockResolvedValue([storeWorkflow()]) syncState.findUniqueOrThrow.mockResolvedValue({ sourceCursorRaw: null }) metricSnapshot.findFirst.mockResolvedValue(null) mssqlMock.__request.query.mockResolvedValueOnce(storeRanges()).mockResolvedValueOnce({ recordset: [ storeRow({ metricDate: '2026-09-16', promotionSourceRowId: null, impressions: null, roi: null }), storeRow({ impressions: '0', roi: '0' }), ] }) transactionSnapshot.findMany.mockResolvedValue([ { sourceKey: 'tmall-store-v1:朴美:1084275176878:2026-09-16', impressions: '888', roi: '3', promotionSpend: '10', promotionRevenue: '30' }, { sourceKey: 'tmall-store-v1:朴美:1084275176878:2026-09-17', impressions: '999', roi: '4' }, ]) await syncPerformanceConversionMetrics() expect(transactionSnapshot.createMany).toHaveBeenCalledWith({ data: [ expect.objectContaining({ impressions: '888', roi: '3' }), expect.objectContaining({ impressions: '0', roi: '0' }), ] }) }) it('fences each snapshot replacement with the transaction-owned live lease', async () => { syncState.findUniqueOrThrow.mockResolvedValue({ sourceCursorRaw: null }) metricSnapshot.findFirst.mockResolvedValue(null) mssqlMock.__request.query.mockResolvedValue({ recordset: [] }) transactionState.updateMany.mockResolvedValue({ count: 0 }) await expect(syncPerformanceConversionMetrics()).rejects.toThrow('经营数据同步租约已失效') expect(transactionSnapshot.deleteMany).not.toHaveBeenCalled() expect(transactionSnapshot.createMany).not.toHaveBeenCalled() }) it.each([ ['2026-10-22T06:59:00Z', 60_000], ['2026-10-22T07:00:00Z', 86_400_000], ['2026-10-22T15:30:00Z', 55_800_000], ['2026-10-31T23:30:00Z', 27_000_000], ])('schedules the next Shanghai 15:00 independently of host timezone at %s', (now, delay) => { expect(nextPerformanceConversionSyncDelay(new Date(now))).toBe(delay) }) })