/** @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 }