gongxiangp2/lib/__tests__/task-detail-upload-runtime.test.ts
chenpei 7326e273e1 fix: harden media upload and release safety
Protect upload cleanup and atomic completion paths, add durable media worker leases and brand asset validation, and introduce verified migration/bootstrap/deployment gates.
2026-08-12 18:04:08 +08:00

124 lines
4.9 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 { mkdtemp, mkdir, readFile, rm, writeFile } from 'fs/promises'
import { tmpdir } from 'os'
import path from 'path'
import {
__taskDetailUploadRuntimeTesting,
acquireTaskDetailUploadExecutionLock,
ensureTaskDetailUploadDrainCapability,
isTaskDetailUploadDraining,
removeTaskDetailUploadActiveMarker,
TASK_DETAIL_UPLOAD_DRAIN_PROTOCOL,
TaskDetailUploadExecutionLockLostError,
TaskDetailUploadExecutionLockUnavailableError,
writeTaskDetailUploadActiveMarker,
} from '@/lib/task-detail-upload-runtime'
const ORIGINAL_ENV = {
UPLOAD_DIR: process.env.UPLOAD_DIR,
TASK_DETAIL_UPLOAD_RUNTIME_DIR: process.env.TASK_DETAIL_UPLOAD_RUNTIME_DIR,
TASK_DETAIL_UPLOAD_WORKER_ID: process.env.TASK_DETAIL_UPLOAD_WORKER_ID,
TASK_DETAIL_UPLOAD_INSTANCE_ID: process.env.TASK_DETAIL_UPLOAD_INSTANCE_ID,
}
let tempRoot: string | null = null
describe('task-detail upload runtime', () => {
beforeEach(async () => {
tempRoot = await mkdtemp(path.join(tmpdir(), 'task-detail-runtime-'))
process.env.UPLOAD_DIR = path.join(tempRoot, 'uploads')
process.env.TASK_DETAIL_UPLOAD_RUNTIME_DIR = path.join(tempRoot, 'runtime')
process.env.TASK_DETAIL_UPLOAD_WORKER_ID = 'worker-test'
process.env.TASK_DETAIL_UPLOAD_INSTANCE_ID = 'instance-test'
})
afterEach(async () => {
if (tempRoot) await rm(tempRoot, { recursive: true, force: true })
tempRoot = null
restoreEnv('UPLOAD_DIR', ORIGINAL_ENV.UPLOAD_DIR)
restoreEnv('TASK_DETAIL_UPLOAD_RUNTIME_DIR', ORIGINAL_ENV.TASK_DETAIL_UPLOAD_RUNTIME_DIR)
restoreEnv('TASK_DETAIL_UPLOAD_WORKER_ID', ORIGINAL_ENV.TASK_DETAIL_UPLOAD_WORKER_ID)
restoreEnv('TASK_DETAIL_UPLOAD_INSTANCE_ID', ORIGINAL_ENV.TASK_DETAIL_UPLOAD_INSTANCE_ID)
jest.useRealTimers()
jest.restoreAllMocks()
})
test('capability/active/drain 使用共享运行时路径,execution lock 固定在 uploads', async () => {
await ensureTaskDetailUploadDrainCapability()
const capability = await readFile(__taskDetailUploadRuntimeTesting.capabilityPath(), 'utf8')
expect(capability).toContain(TASK_DETAIL_UPLOAD_DRAIN_PROTOCOL)
expect(capability).toContain('workerId=worker-test')
expect(await isTaskDetailUploadDraining()).toBe(false)
await writeTaskDetailUploadActiveMarker('job-1', 'lease-1')
const active = JSON.parse(await readFile(__taskDetailUploadRuntimeTesting.activePath(), 'utf8'))
expect(active).toEqual(expect.objectContaining({
protocol: TASK_DETAIL_UPLOAD_DRAIN_PROTOCOL,
workerId: 'worker-test',
instanceId: 'instance-test',
jobId: 'job-1',
leaseToken: 'lease-1',
}))
await mkdir(path.dirname(__taskDetailUploadRuntimeTesting.drainPath()), { recursive: true })
await writeFile(__taskDetailUploadRuntimeTesting.drainPath(), 'drain\n', 'utf8')
expect(await isTaskDetailUploadDraining()).toBe(true)
expect(__taskDetailUploadRuntimeTesting.executionLockPath()).toBe(
path.join(process.env.UPLOAD_DIR!, '.locks', 'task-detail-upload-execution.lock'),
)
await removeTaskDetailUploadActiveMarker()
await expect(readFile(__taskDetailUploadRuntimeTesting.activePath(), 'utf8')).rejects.toMatchObject({
code: 'ENOENT',
})
})
test('drain 路径发生非 ENOENT I/O 错误时必须 fail-closed', async () => {
jest.spyOn(console, 'error').mockImplementation(() => undefined)
const invalidRuntimeRoot = path.join(tempRoot!, 'runtime-as-file')
await writeFile(invalidRuntimeRoot, 'not a directory', 'utf8')
process.env.TASK_DETAIL_UPLOAD_RUNTIME_DIR = invalidRuntimeRoot
await expect(isTaskDetailUploadDraining()).resolves.toBe(true)
})
test('非生产执行锁也严格串行,释放后下一个才能进入', async () => {
const first = await acquireTaskDetailUploadExecutionLock({ timeoutMs: 1_000 })
let secondResolved = false
const secondPromise = acquireTaskDetailUploadExecutionLock({ timeoutMs: 1_000 }).then((release) => {
secondResolved = true
return release
})
await Promise.resolve()
expect(secondResolved).toBe(false)
first.assertHeld()
await first()
expect(() => first.assertHeld()).toThrow(TaskDetailUploadExecutionLockLostError)
const second = await secondPromise
expect(secondResolved).toBe(true)
second.assertHeld()
await second()
})
test('执行锁等待超时时拒绝降级并发', async () => {
jest.useFakeTimers()
const first = await acquireTaskDetailUploadExecutionLock({ timeoutMs: 1_000 })
const waiting = acquireTaskDetailUploadExecutionLock({ timeoutMs: 1_000 })
const assertion = expect(waiting).rejects.toBeInstanceOf(
TaskDetailUploadExecutionLockUnavailableError,
)
await jest.advanceTimersByTimeAsync(1_000)
await assertion
await first()
})
})
function restoreEnv(name: string, value: string | undefined) {
if (value === undefined) delete process.env[name]
else process.env[name] = value
}