120 lines
6.6 KiB
TypeScript
120 lines
6.6 KiB
TypeScript
/** @jest-environment node */
|
|
|
|
import mssql from 'mssql'
|
|
import { prisma } from '@/lib/prisma'
|
|
import { getPerformanceConversionSyncMetadata, getPerformanceConversionTimeseries } from '@/lib/performance-conversion'
|
|
|
|
jest.mock('@/lib/brand-center-dashboard-access', () => ({ assertBrandCenterDashboardAccess: jest.fn().mockResolvedValue(undefined) }))
|
|
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),
|
|
}))
|
|
jest.mock('@/lib/prisma', () => ({ prisma: {
|
|
dingWorkflowSnapshot: { findUnique: jest.fn() },
|
|
performanceConversionSyncState: { findUnique: jest.fn() },
|
|
performanceConversionMetricSnapshot: { findMany: jest.fn() },
|
|
} }))
|
|
jest.mock('mssql', () => {
|
|
const request = { input: jest.fn(), query: jest.fn() }
|
|
request.input.mockReturnValue(request)
|
|
const pool = { connect: jest.fn(), close: jest.fn(), request: () => request }
|
|
return { __esModule: true, default: {
|
|
ConnectionPool: jest.fn(() => pool), NVarChar: (length: number) => length,
|
|
__request: request, __pool: pool,
|
|
} }
|
|
})
|
|
|
|
const workflow = prisma.dingWorkflowSnapshot.findUnique as jest.Mock
|
|
const state = prisma.performanceConversionSyncState.findUnique as jest.Mock
|
|
const localRows = prisma.performanceConversionMetricSnapshot.findMany as jest.Mock
|
|
const mockSql = mssql as typeof mssql & { __request: { input: jest.Mock; query: jest.Mock }; __pool: { connect: jest.Mock; close: jest.Mock } }
|
|
const productId = '1076069062942'
|
|
function metricRow(overrides = {}) {
|
|
return {
|
|
sourceRowId: '10', metricDate: new Date('2026-10-20T00:00:00Z'), productId,
|
|
sourceBrand: '朴美家具旗舰店', productName: '测试商品',
|
|
visitors: '100', paymentUsers: '3', conversionRate: '2.5%',
|
|
clickRate: '1.5%', clicks: '80', impressions: '1000',
|
|
promotionSpend: '10', promotionRevenue: '50', roi: '2.75',
|
|
favorites: '5', cartUsers: '6', paymentAmount: '150', ...overrides,
|
|
}
|
|
}
|
|
|
|
describe('source-isolated performance conversion reads', () => {
|
|
beforeAll(() => jest.useFakeTimers().setSystemTime(new Date('2026-10-22T04:00:00Z')))
|
|
afterAll(() => jest.useRealTimers())
|
|
beforeEach(() => {
|
|
jest.clearAllMocks()
|
|
workflow.mockResolvedValue({
|
|
processInstanceId: 'flow-pumei', businessId: 'approval', title: '测试',
|
|
result: 'agree', bizAction: 'finish', status: 'completed', processCode: 'art-process',
|
|
brand: '朴美', sourceFinishedAtRaw: '2026-10-01T08:00:00Z', formFields: { productId, platform: '天猫' },
|
|
})
|
|
state.mockResolvedValue({
|
|
status: 'ready', lastCompletedAt: new Date('2026-10-21T07:00:00Z'),
|
|
sourceCursorRaw: JSON.stringify({ version: 2, lastSourceDate: '2026-10-21', tmallStoreDates: { 朴美: '2026-10-20', 顾家: '2026-10-19' } }),
|
|
})
|
|
localRows.mockResolvedValue([metricRow()])
|
|
mockSql.__pool.connect.mockResolvedValue(undefined)
|
|
mockSql.__pool.close.mockResolvedValue(undefined)
|
|
mockSql.__request.query.mockResolvedValue({ recordset: [] })
|
|
process.env.REPORT_DB_SERVER = 'report.internal'
|
|
process.env.REPORT_DB_DATABASE = 'report'
|
|
process.env.REPORT_DB_USER = 'reader'
|
|
process.env.REPORT_DB_PASSWORD = 'test-only'
|
|
})
|
|
|
|
it('uses the reported Overall click/conversion rates and reported ROI, not unrelated derived ratios', async () => {
|
|
const result = await getPerformanceConversionTimeseries(null, { workflowId: 'flow-pumei', productId })
|
|
expect(result.latest).toMatchObject({ clickRate: 0.015, paymentConversionRate: 0.025, roi: 2.75, impressions: 1000 })
|
|
expect(result.product.brandMatchesWorkflow).toBe(true)
|
|
expect(localRows).toHaveBeenCalledWith(expect.objectContaining({ where: expect.objectContaining({
|
|
productId, sourceKey: { startsWith: 'tmall-store-v1:朴美:' },
|
|
}) }))
|
|
expect(mockSql.__pool.connect).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('keeps null, zero and missing calendar days distinct', async () => {
|
|
localRows.mockResolvedValue([metricRow({ impressions: null, roi: '0', favorites: null })])
|
|
const result = await getPerformanceConversionTimeseries(null, { workflowId: 'flow-pumei', productId })
|
|
expect(result.latest).toMatchObject({ impressions: null, roi: 0, favorites: null })
|
|
expect(result.daily[0]).toMatchObject({ date: '2026-10-01', impressions: null, roi: null })
|
|
})
|
|
|
|
it('does not label the old cursor as a completed sync for a newly introduced source', async () => {
|
|
state.mockResolvedValue({ status: 'ready', lastCompletedAt: new Date(), sourceCursorRaw: JSON.stringify({ version: 1, lastSourceDate: '2026-10-21' }) })
|
|
await expect(getPerformanceConversionSyncMetadata('朴美')).resolves.toEqual({ status: 'pending', lastCompletedAt: null, lastSourceDate: null })
|
|
await expect(getPerformanceConversionSyncMetadata('亿家达')).resolves.toMatchObject({ status: 'ready', lastSourceDate: '2026-10-21' })
|
|
})
|
|
|
|
it('reports each store actual progress separately instead of the current calendar date', async () => {
|
|
await expect(getPerformanceConversionSyncMetadata('朴美')).resolves.toMatchObject({ lastSourceDate: '2026-10-20' })
|
|
await expect(getPerformanceConversionSyncMetadata('顾家')).resolves.toMatchObject({ lastSourceDate: '2026-10-19' })
|
|
await expect(getPerformanceConversionSyncMetadata('索菲亚')).resolves.toMatchObject({ status: 'pending', lastSourceDate: null })
|
|
})
|
|
|
|
it('reads the same new source during the pre-snapshot bootstrap gap', async () => {
|
|
const newProductId = '1076069062943'
|
|
const row = await workflow()
|
|
workflow.mockResolvedValue({ ...row, formFields: { productId: newProductId } })
|
|
state.mockResolvedValue(null)
|
|
mockSql.__request.query.mockResolvedValue({ recordset: [metricRow({ productId: newProductId, clicks: null })] })
|
|
const result = await getPerformanceConversionTimeseries(null, { workflowId: 'flow-pumei', productId: newProductId })
|
|
expect(result.latest?.roi).toBe(2.75)
|
|
expect(mockSql.__request.query.mock.calls[0][0]).toContain('FROM [rpt].[TmallStoreOverall] o')
|
|
expect(mockSql.__request.input).toHaveBeenCalledWith('store', expect.anything(), '朴美家具旗舰店')
|
|
expect(localRows).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('does not attach Tmall metrics to a same-ID workflow explicitly on another platform', async () => {
|
|
const row = await workflow()
|
|
workflow.mockResolvedValue({ ...row, formFields: { productId, platform: '京东' } })
|
|
const result = await getPerformanceConversionTimeseries(null, { workflowId: 'flow-pumei', productId })
|
|
expect(result.latest).toBeNull()
|
|
expect(localRows).not.toHaveBeenCalled()
|
|
expect(mockSql.__pool.connect).not.toHaveBeenCalled()
|
|
})
|
|
})
|