gongxiangp2/lib/__tests__/task-detail-upload-jobs.test.ts
chenpei 2b5220fdd7 fix: 任务-详情源文件不再误套 1GB 图片上限
PSD/压缩包按单文件 10GB、合计 30GB 单独限制;提交时续上传会话,避免大文件传完再排队后会话丢失。
2026-08-17 18:48:00 +08:00

465 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/** @jest-environment node */
import type { Prisma } from '@prisma/client'
import { prisma } from '@/lib/prisma'
import {
__taskDetailUploadJobTesting,
acquireTaskDetailUploadLock,
createTaskDetailUploadJob,
recoverTaskDetailUploadJobs,
TaskDetailUploadQueueCapacityError,
triggerTaskDetailUploadJob,
} from '@/lib/task-detail-upload-jobs'
import { completeProductPackageUpload } from '@/lib/product-package-completion'
import {
acquireTaskDetailUploadExecutionLock,
ensureTaskDetailUploadDrainCapability,
isTaskDetailUploadDraining,
removeTaskDetailUploadActiveMarker,
writeTaskDetailUploadActiveMarker,
} from '@/lib/task-detail-upload-runtime'
jest.mock('@/lib/prisma', () => ({
prisma: {
$transaction: jest.fn(),
$queryRawUnsafe: jest.fn(),
productPackageUploadJob: {
findFirst: jest.fn(),
findUnique: jest.fn(),
findMany: jest.fn(),
count: jest.fn(),
create: jest.fn(),
updateMany: jest.fn(),
},
materialAsset: {
count: jest.fn(),
},
material: {
update: jest.fn(),
updateMany: jest.fn(),
},
},
}))
jest.mock('@/lib/task-detail-upload-runtime', () => ({
acquireTaskDetailUploadExecutionLock: jest.fn(),
ensureTaskDetailUploadDrainCapability: jest.fn().mockResolvedValue(undefined),
getTaskDetailUploadWorkerIdentity: jest.fn(() => ({ workerId: 'worker-test', instanceId: 'instance-test' })),
isTaskDetailUploadDraining: jest.fn().mockResolvedValue(false),
removeTaskDetailUploadActiveMarker: jest.fn().mockResolvedValue(undefined),
writeTaskDetailUploadActiveMarker: jest.fn().mockResolvedValue(undefined),
}))
jest.mock('@/lib/product-package-completion', () => ({
assertTaskDetailUploadAssetLimits: jest.fn(),
assertOwnedProductPackageUploadSessions: jest.fn(),
completeProductPackageUpload: jest.fn(),
}))
jest.mock('@/lib/upload-sessions', () => ({
refreshOwnedUploadSessionTtls: jest.fn().mockResolvedValue(undefined),
}))
const now = new Date('2026-08-01T00:00:00.000Z')
const releaseExecutionLock = Object.assign(jest.fn(async () => undefined), {
assertHeld: jest.fn(),
})
function job(overrides: Record<string, unknown> = {}) {
return {
id: 'job-1',
materialId: 'material-1',
operatorUserId: 'user-1',
taskNo: null,
dingtalkApprovalNo: null,
assets: { main: [] },
options: null,
status: 'queued',
stage: 'queued',
progress: 0,
errorMessage: null,
createdAt: now,
startedAt: null,
completedAt: null,
updatedAt: now,
...overrides,
}
}
function transactionClient(options: { activeCount?: number; claimedCount?: number } = {}) {
return {
$queryRaw: jest.fn().mockResolvedValue([{ locked: 1 }]),
$queryRawUnsafe: jest.fn().mockResolvedValue([{ now }]),
productPackageUploadJob: {
count: jest.fn()
.mockResolvedValueOnce(options.activeCount ?? 0)
.mockResolvedValue(1),
updateMany: jest.fn().mockResolvedValue({ count: options.claimedCount ?? 1 }),
findFirst: jest.fn(),
create: jest.fn(),
},
material: {
update: jest.fn(),
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
},
} as any
}
async function flushMicrotasks(rounds = 8) {
for (let index = 0; index < rounds; index += 1) await Promise.resolve()
}
describe('task-detail upload queue', () => {
beforeEach(() => {
jest.clearAllMocks()
releaseExecutionLock.mockReset().mockResolvedValue(undefined)
releaseExecutionLock.assertHeld.mockReset()
__taskDetailUploadJobTesting.resetRuntimeState()
;(prisma.productPackageUploadJob.findMany as jest.Mock).mockResolvedValue([])
;(prisma.productPackageUploadJob.updateMany as jest.Mock).mockResolvedValue({ count: 1 })
;(prisma.material.updateMany as jest.Mock).mockResolvedValue({ count: 1 })
;(prisma.materialAsset.count as jest.Mock).mockResolvedValue(0)
;(completeProductPackageUpload as jest.Mock).mockResolvedValue(undefined)
;(prisma.$queryRawUnsafe as jest.Mock).mockResolvedValue([{ now }])
;(ensureTaskDetailUploadDrainCapability as jest.Mock).mockResolvedValue(undefined)
;(acquireTaskDetailUploadExecutionLock as jest.Mock).mockResolvedValue(releaseExecutionLock)
;(isTaskDetailUploadDraining as jest.Mock).mockResolvedValue(false)
;(writeTaskDetailUploadActiveMarker as jest.Mock).mockResolvedValue(undefined)
;(removeTaskDetailUploadActiveMarker as jest.Mock).mockResolvedValue(undefined)
})
afterEach(() => {
__taskDetailUploadJobTesting.resetRuntimeState()
jest.useRealTimers()
jest.restoreAllMocks()
})
test('advisory lock 只返回 Prisma 可反序列化的整数列', async () => {
const tx = {
$queryRaw: jest.fn().mockResolvedValue([{ locked: 1 }]),
} as unknown as Prisma.TransactionClient
await acquireTaskDetailUploadLock(tx, 'product-package-upload-create')
const [template, lockName] = (tx.$queryRaw as jest.Mock).mock.calls[0]
const sql = Array.from(template as TemplateStringsArray).join('?')
expect(sql).toContain('SELECT 1 AS locked')
expect(sql).toContain('pg_advisory_xact_lock(hashtext(?)')
expect(sql.trim()).not.toMatch(/^SELECT\s+pg_advisory_xact_lock/i)
expect(lockName).toBe('product-package-upload-create')
})
test('重复 create 复用同一任务且不新增队列记录', async () => {
const existing = job({ status: 'completed', stage: 'completed', progress: 100, completedAt: now })
const tx = transactionClient()
tx.productPackageUploadJob.findFirst.mockResolvedValue(existing)
;(prisma.$transaction as jest.Mock).mockImplementation(async (callback) => callback(tx))
;(prisma.productPackageUploadJob.findUnique as jest.Mock).mockResolvedValue(existing)
const input = {
materialId: 'material-1',
operatorUserId: 'user-1',
assets: { main: [] },
}
const first = await createTaskDetailUploadJob(input)
const second = await createTaskDetailUploadJob(input)
await flushMicrotasks()
expect(first.jobId).toBe('job-1')
expect(second.jobId).toBe('job-1')
expect(tx.productPackageUploadJob.create).not.toHaveBeenCalled()
expect(tx.productPackageUploadJob.count).not.toHaveBeenCalled()
})
test.each([
['user', [3], TaskDetailUploadQueueCapacityError, 429],
['global', [2, 20], TaskDetailUploadQueueCapacityError, 503],
])('等待队列 %s 容量满时拒绝创建', async (scope, counts, errorType, statusCode) => {
const tx = transactionClient()
tx.productPackageUploadJob.findFirst.mockResolvedValue(null)
tx.productPackageUploadJob.count.mockReset().mockImplementation(async () => counts.shift() ?? 0)
;(prisma.$transaction as jest.Mock).mockImplementation(async (callback) => callback(tx))
await expect(createTaskDetailUploadJob({
materialId: `material-${scope}`,
operatorUserId: 'user-1',
assets: { main: [] },
})).rejects.toMatchObject({
constructor: errorType,
scope,
statusCode,
})
expect(tx.productPackageUploadJob.create).not.toHaveBeenCalled()
})
test('跨容器领取在 advisory lock 内检查全局 processing 并使用 queued 条件更新', async () => {
const firstTx = transactionClient({ activeCount: 0, claimedCount: 1 })
const secondTx = transactionClient({ activeCount: 1, claimedCount: 1 })
;(prisma.$transaction as jest.Mock)
.mockImplementationOnce(async (callback) => callback(firstTx))
.mockImplementationOnce(async (callback) => callback(secondTx))
await expect(__taskDetailUploadJobTesting.claimTaskDetailUploadJob('job-1')).resolves.toEqual(
expect.objectContaining({ status: 'acquired', token: expect.any(String) }),
)
await expect(__taskDetailUploadJobTesting.claimTaskDetailUploadJob('job-2')).resolves.toEqual({ status: 'busy' })
expect(firstTx.productPackageUploadJob.updateMany).toHaveBeenCalledWith(expect.objectContaining({
where: { id: 'job-1', status: 'queued' },
data: expect.objectContaining({ status: 'processing' }),
}))
expect(secondTx.productPackageUploadJob.updateMany).not.toHaveBeenCalled()
})
test('已提交素材会直接补记 completed,不会重跑已清理的上传会话', async () => {
;(prisma.productPackageUploadJob.findUnique as jest.Mock).mockResolvedValue({
id: 'job-1',
status: 'processing',
materialId: 'material-1',
material: { taskStatus: 'completed', zipFilePath: 'all.zip' },
})
;(prisma.materialAsset.count as jest.Mock).mockResolvedValue(3)
await expect(
__taskDetailUploadJobTesting.reconcileCommittedTaskDetailUploadJob('job-1'),
).resolves.toBe(true)
expect(prisma.productPackageUploadJob.updateMany).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({ status: 'completed', progress: 100 }),
}))
expect(completeProductPackageUpload).not.toHaveBeenCalled()
})
test('worker 处理期间每 30 秒写一次 heartbeat,完成后清除并写 completed', async () => {
jest.useFakeTimers()
const tx = transactionClient({ activeCount: 0, claimedCount: 1 })
;(prisma.$transaction as jest.Mock).mockImplementation(async (callback) => callback(tx))
;(prisma.productPackageUploadJob.findUnique as jest.Mock)
.mockResolvedValueOnce({
id: 'job-1',
status: 'queued',
materialId: 'material-1',
material: { taskStatus: 'uploading', zipFilePath: '' },
})
.mockResolvedValueOnce(job())
let finish!: () => void
;(completeProductPackageUpload as jest.Mock).mockImplementation(() => new Promise<void>((resolve) => {
finish = resolve
}))
const processing = __taskDetailUploadJobTesting.processOneTaskDetailUploadJob('job-1')
await flushMicrotasks()
await jest.advanceTimersByTimeAsync(30_000)
expect(tx.productPackageUploadJob.updateMany).toHaveBeenCalledWith(expect.objectContaining({
where: expect.objectContaining({
id: 'job-1',
status: 'processing',
leaseToken: expect.any(String),
}),
data: expect.objectContaining({ leaseExpiresAt: expect.any(Date) }),
}))
finish()
await expect(processing).resolves.toBe('processed')
expect(tx.productPackageUploadJob.updateMany).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({ status: 'completed', progress: 100 }),
}))
expect(acquireTaskDetailUploadExecutionLock).toHaveBeenCalledTimes(1)
expect(releaseExecutionLock.assertHeld).toHaveBeenCalled()
expect(releaseExecutionLock).toHaveBeenCalledTimes(1)
})
test('active marker 落盘后才出现 drain 时释放租约并保持 queued,不开始重处理', async () => {
const tx = transactionClient({ activeCount: 0, claimedCount: 1 })
;(prisma.$transaction as jest.Mock).mockImplementation(async (callback) => callback(tx))
;(prisma.productPackageUploadJob.findUnique as jest.Mock)
.mockResolvedValueOnce({
id: 'job-1',
status: 'queued',
materialId: 'material-1',
material: { taskStatus: 'uploading', zipFilePath: '' },
})
.mockResolvedValueOnce(job())
;(isTaskDetailUploadDraining as jest.Mock)
.mockResolvedValueOnce(false)
.mockResolvedValueOnce(true)
await expect(
__taskDetailUploadJobTesting.processOneTaskDetailUploadJob('job-1'),
).resolves.toBe('busy')
expect(writeTaskDetailUploadActiveMarker).toHaveBeenCalledTimes(1)
expect(completeProductPackageUpload).not.toHaveBeenCalled()
expect(tx.productPackageUploadJob.updateMany).toHaveBeenCalledWith(expect.objectContaining({
where: expect.objectContaining({ id: 'job-1', leaseToken: expect.any(String) }),
data: expect.objectContaining({
status: 'queued',
stage: 'queued',
leaseToken: null,
leaseExpiresAt: null,
}),
}))
expect(removeTaskDetailUploadActiveMarker).toHaveBeenCalledTimes(1)
expect(releaseExecutionLock).toHaveBeenCalledTimes(1)
})
test('heartbeat 续租失败后中止作业,不写 completed/failed 也不改素材失败状态', async () => {
jest.useFakeTimers()
const tx = transactionClient({ activeCount: 0, claimedCount: 1 })
tx.productPackageUploadJob.updateMany
.mockResolvedValueOnce({ count: 1 }) // claim
.mockResolvedValueOnce({ count: 0 }) // heartbeat lease renew lost
.mockResolvedValue({ count: 0 })
jest.spyOn(console, 'error').mockImplementation(() => undefined)
jest.spyOn(console, 'warn').mockImplementation(() => undefined)
;(prisma.$transaction as jest.Mock).mockImplementation(async (callback) => callback(tx))
;(prisma.productPackageUploadJob.findUnique as jest.Mock)
.mockResolvedValueOnce({
id: 'job-1',
status: 'queued',
materialId: 'material-1',
material: { taskStatus: 'uploading', zipFilePath: '' },
})
.mockResolvedValueOnce(job())
let finish!: () => void
;(completeProductPackageUpload as jest.Mock).mockImplementation(() => new Promise<void>((resolve) => {
finish = resolve
}))
const processing = __taskDetailUploadJobTesting.processOneTaskDetailUploadJob('job-1')
await flushMicrotasks()
expect(releaseExecutionLock).not.toHaveBeenCalled()
await jest.advanceTimersByTimeAsync(30_000)
finish()
await expect(processing).resolves.toBe('processed')
const statusWrites = tx.productPackageUploadJob.updateMany.mock.calls
.map(([input]: [any]) => input?.data?.status)
.filter((status: unknown) => status === 'completed' || status === 'failed')
expect(statusWrites).toEqual([])
expect(prisma.material.updateMany).not.toHaveBeenCalled()
expect(tx.material.updateMany).not.toHaveBeenCalled()
const processorOptions = (completeProductPackageUpload as jest.Mock).mock.calls[0][0].options
expect(processorOptions.signal.aborted).toBe(true)
expect(releaseExecutionLock).toHaveBeenCalledTimes(1)
})
test('共享 uploads 执行锁不可用时只背压重试,不领租约也不运行处理器', async () => {
jest.spyOn(console, 'error').mockImplementation(() => undefined)
;(prisma.productPackageUploadJob.findUnique as jest.Mock).mockResolvedValueOnce({
id: 'job-1',
status: 'queued',
materialId: 'material-1',
material: { taskStatus: 'uploading', zipFilePath: '' },
})
;(acquireTaskDetailUploadExecutionLock as jest.Mock).mockRejectedValueOnce(
new Error('flock unavailable'),
)
await expect(
__taskDetailUploadJobTesting.processOneTaskDetailUploadJob('job-1'),
).resolves.toBe('busy')
expect(prisma.$transaction).not.toHaveBeenCalled()
expect(completeProductPackageUpload).not.toHaveBeenCalled()
expect(writeTaskDetailUploadActiveMarker).not.toHaveBeenCalled()
})
test('处理失败会写 failed 并同步素材状态', async () => {
jest.spyOn(console, 'error').mockImplementation(() => undefined)
const tx = transactionClient({ activeCount: 0, claimedCount: 1 })
;(prisma.$transaction as jest.Mock).mockImplementation(async (callback) => callback(tx))
;(prisma.productPackageUploadJob.findUnique as jest.Mock)
.mockResolvedValueOnce({
id: 'job-1',
status: 'queued',
materialId: 'material-1',
material: { taskStatus: 'uploading', zipFilePath: '' },
})
.mockResolvedValueOnce(job())
;(completeProductPackageUpload as jest.Mock).mockRejectedValue(new Error('恶意预览被拒绝'))
await expect(
__taskDetailUploadJobTesting.processOneTaskDetailUploadJob('job-1'),
).resolves.toBe('processed')
expect(tx.productPackageUploadJob.updateMany).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({
status: 'failed',
stage: 'failed',
errorMessage: '恶意预览被拒绝',
}),
}))
expect(tx.material.updateMany).toHaveBeenCalledWith({
where: { id: 'material-1', taskStatus: { not: 'completed' } },
data: { taskStatus: 'failed' },
})
})
test('leaseToken 有值但 leaseExpiresAt 为 null 时在 DB clock + advisory lock 内 CAS 回收', async () => {
;(prisma.productPackageUploadJob.findMany as jest.Mock)
.mockResolvedValueOnce([{ id: 'stale-1', materialId: 'material-stale', leaseToken: 'expired-token' }])
.mockResolvedValueOnce([])
;(prisma.productPackageUploadJob.findUnique as jest.Mock).mockResolvedValue({
id: 'stale-1',
status: 'processing',
materialId: 'material-stale',
material: { taskStatus: 'uploading', zipFilePath: '' },
})
const recoveryTx = transactionClient()
;(prisma.$transaction as jest.Mock).mockImplementation(async (callback) => callback(recoveryTx))
await expect(recoverTaskDetailUploadJobs()).resolves.toBe(0)
expect(recoveryTx.productPackageUploadJob.updateMany).toHaveBeenCalledWith(expect.objectContaining({
where: expect.objectContaining({
id: 'stale-1',
status: 'processing',
leaseToken: 'expired-token',
OR: [
{ leaseExpiresAt: { lte: now } },
{ leaseExpiresAt: null },
],
}),
data: expect.objectContaining({ status: 'queued', stage: 'queued', leaseToken: null }),
}))
expect(recoveryTx.$queryRaw).toHaveBeenCalled()
expect(recoveryTx.$queryRawUnsafe).toHaveBeenCalledWith('SELECT clock_timestamp() AS "now"')
expect(recoveryTx.material.updateMany).toHaveBeenCalledWith(expect.objectContaining({
where: { id: 'material-stale', taskStatus: { not: 'completed' } },
data: { taskStatus: 'uploading' },
}))
})
test('全局已有 processing 时 20 个 queued 只保留一个 retry timer,不形成 per-job 自旋', async () => {
jest.useFakeTimers()
const tx = transactionClient({ activeCount: 1, claimedCount: 0 })
;(prisma.$transaction as jest.Mock).mockImplementation(async (callback) => callback(tx))
;(prisma.productPackageUploadJob.findUnique as jest.Mock)
.mockResolvedValueOnce({
id: 'job-0',
status: 'queued',
materialId: 'material-0',
material: { taskStatus: 'uploading', zipFilePath: '' },
})
.mockResolvedValueOnce({ status: 'queued' })
for (let index = 0; index < 20; index += 1) triggerTaskDetailUploadJob(`job-${index}`)
await flushMicrotasks(20)
const state = __taskDetailUploadJobTesting.getRuntimeState()
expect(state.pendingJobIds).toHaveLength(20)
expect(state.retryScheduled).toBe(true)
expect(state.globallyBackpressured).toBe(true)
expect(jest.getTimerCount()).toBe(1)
// recovery/新请求在背压期间只能补 pending,不能再创建第二条重试链。
triggerTaskDetailUploadJob('job-extra')
await flushMicrotasks()
expect(__taskDetailUploadJobTesting.getRuntimeState().pendingJobIds).toHaveLength(21)
expect(jest.getTimerCount()).toBe(1)
})
})