gongxiangp2/lib/__tests__/performance-conversion-sync.test.ts

164 lines
5.8 KiB
TypeScript

/** @jest-environment node */
import mssql from 'mssql'
import { prisma } from '@/lib/prisma'
import { 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 = { deleteMany: jest.fn(), createMany: 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,
}))
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',
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('经营数据同步租约已失效')
})
})