Protect upload cleanup and atomic completion paths, add durable media worker leases and brand asset validation, and introduce verified migration/bootstrap/deployment gates.
124 lines
4.9 KiB
TypeScript
124 lines
4.9 KiB
TypeScript
/** @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
|
||
}
|