332 lines
14 KiB
TypeScript
332 lines
14 KiB
TypeScript
/** @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)
|
|
})
|
|
})
|