Protect upload cleanup and atomic completion paths, add durable media worker leases and brand asset validation, and introduce verified migration/bootstrap/deployment gates.
163 lines
4.6 KiB
TypeScript
163 lines
4.6 KiB
TypeScript
/** @jest-environment node */
|
|
|
|
const mockExecFile = jest.fn()
|
|
const mockReadFile = jest.fn()
|
|
const mockUnlink = jest.fn()
|
|
const mockSharp = jest.fn()
|
|
const mockEnsureEnoughDiskSpace = jest.fn()
|
|
|
|
jest.mock('child_process', () => ({
|
|
...jest.requireActual('child_process'),
|
|
execFile: mockExecFile,
|
|
}))
|
|
|
|
jest.mock('fs/promises', () => ({
|
|
readFile: mockReadFile,
|
|
unlink: mockUnlink,
|
|
writeFile: jest.fn(),
|
|
open: jest.fn(),
|
|
}))
|
|
|
|
jest.mock('sharp', () => ({
|
|
__esModule: true,
|
|
default: mockSharp,
|
|
}))
|
|
|
|
jest.mock('@/lib/sharp-runtime', () => ({
|
|
configureSharpRuntime: jest.fn(),
|
|
}))
|
|
|
|
jest.mock('@/lib/disk-space', () => ({
|
|
ensureEnoughDiskSpace: mockEnsureEnoughDiskSpace,
|
|
}))
|
|
|
|
async function waitUntil(condition: () => boolean): Promise<void> {
|
|
for (let attempt = 0; attempt < 20; attempt += 1) {
|
|
if (condition()) {
|
|
return
|
|
}
|
|
await new Promise<void>((resolve) => setImmediate(resolve))
|
|
}
|
|
|
|
throw new Error('等待并发测试状态超时')
|
|
}
|
|
|
|
describe('generatePdfPreview image-processing limiter', () => {
|
|
afterEach(() => {
|
|
delete process.env.IMAGE_PROCESSING_MAX_CONCURRENT
|
|
})
|
|
|
|
test('两个 PDF 预览的 pdftoppm 与 Sharp 完整重处理段最大并发为 1', async () => {
|
|
process.env.IMAGE_PROCESSING_MAX_CONCURRENT = '1'
|
|
jest.resetModules()
|
|
jest.clearAllMocks()
|
|
|
|
const events: string[] = []
|
|
const execReleases: Array<() => void> = []
|
|
const sharpWriteReleases: Array<() => void> = []
|
|
let activeHeavyJobs = 0
|
|
let peakHeavyJobs = 0
|
|
let sourceImageCount = 0
|
|
let finalImageCount = 0
|
|
|
|
mockEnsureEnoughDiskSpace.mockResolvedValue(undefined)
|
|
mockReadFile.mockResolvedValue(Buffer.from('pdf-preview-image'))
|
|
mockUnlink.mockResolvedValue(undefined)
|
|
|
|
mockExecFile.mockImplementation((...args: unknown[]) => {
|
|
const callback = args[args.length - 1] as (
|
|
error: Error | null,
|
|
result: { stdout: string; stderr: string }
|
|
) => void
|
|
const jobNumber = execReleases.length + 1
|
|
|
|
activeHeavyJobs += 1
|
|
peakHeavyJobs = Math.max(peakHeavyJobs, activeHeavyJobs)
|
|
events.push(`pdftoppm:start:${jobNumber}`)
|
|
execReleases.push(() => callback(null, { stdout: '', stderr: '' }))
|
|
|
|
return {} as never
|
|
})
|
|
|
|
mockSharp.mockImplementation((input: Buffer | string) => {
|
|
if (Buffer.isBuffer(input)) {
|
|
const jobIndex = sourceImageCount
|
|
sourceImageCount += 1
|
|
const instance: any = {}
|
|
|
|
instance.metadata = jest.fn().mockResolvedValue({ width: 800, height: 600 })
|
|
instance.extract = jest.fn().mockReturnValue(instance)
|
|
instance.resize = jest.fn().mockReturnValue(instance)
|
|
instance.jpeg = jest.fn().mockReturnValue(instance)
|
|
instance.toFile = jest.fn(() => {
|
|
events.push(`sharp:write:${jobIndex + 1}`)
|
|
return new Promise<void>((resolve) => {
|
|
sharpWriteReleases[jobIndex] = resolve
|
|
})
|
|
})
|
|
|
|
return instance
|
|
}
|
|
|
|
const jobNumber = finalImageCount + 1
|
|
finalImageCount += 1
|
|
return {
|
|
metadata: jest.fn().mockImplementation(async () => {
|
|
events.push(`sharp:complete:${jobNumber}`)
|
|
activeHeavyJobs -= 1
|
|
return { width: 800, height: 600 }
|
|
}),
|
|
}
|
|
})
|
|
|
|
const { generatePdfPreview } = await import('@/lib/pdf-processor')
|
|
|
|
const firstPreview = generatePdfPreview('first.pdf', 'first-preview.jpg')
|
|
const secondPreview = generatePdfPreview('second.pdf', 'second-preview.jpg')
|
|
|
|
await waitUntil(() => execReleases.length === 1)
|
|
expect(mockExecFile).toHaveBeenCalledTimes(1)
|
|
expect(activeHeavyJobs).toBe(1)
|
|
|
|
execReleases[0]()
|
|
await waitUntil(() => sharpWriteReleases.length === 1)
|
|
|
|
// 第一个 pdftoppm 已结束,但 Sharp 仍在写盘;第二个 PDF 仍必须排队。
|
|
expect(mockExecFile).toHaveBeenCalledTimes(1)
|
|
expect(activeHeavyJobs).toBe(1)
|
|
|
|
sharpWriteReleases[0]()
|
|
await expect(firstPreview).resolves.toEqual({
|
|
success: true,
|
|
previewPath: 'first-preview.jpg',
|
|
width: 800,
|
|
height: 600,
|
|
})
|
|
|
|
await waitUntil(() => execReleases.length === 2)
|
|
expect(activeHeavyJobs).toBe(1)
|
|
|
|
execReleases[1]()
|
|
await waitUntil(() => sharpWriteReleases.length === 2)
|
|
sharpWriteReleases[1]()
|
|
|
|
await expect(secondPreview).resolves.toEqual({
|
|
success: true,
|
|
previewPath: 'second-preview.jpg',
|
|
width: 800,
|
|
height: 600,
|
|
})
|
|
|
|
expect(peakHeavyJobs).toBe(1)
|
|
expect(activeHeavyJobs).toBe(0)
|
|
expect(events).toEqual([
|
|
'pdftoppm:start:1',
|
|
'sharp:write:1',
|
|
'sharp:complete:1',
|
|
'pdftoppm:start:2',
|
|
'sharp:write:2',
|
|
'sharp:complete:2',
|
|
])
|
|
})
|
|
})
|