gongxiangp2/lib/__tests__/material-upload-safe-cleanup.test.ts
2026-09-16 15:28:42 +08:00

265 lines
9.4 KiB
TypeScript

/** @jest-environment node */
import {
mkdir,
mkdtemp,
readFile,
rm,
stat,
utimes,
writeFile,
} from 'fs/promises'
import os from 'os'
import path from 'path'
import {
beginUploadCompletionSeal,
completeUploadCompletionSeal,
readUploadSessionSeal,
} from '@/lib/material-upload-session-seal'
import {
cleanupConsumedUploadSession,
safelyCleanupMaterialUploadSession,
} from '@/lib/temp-cleanup'
import { uploadSessions, type UploadSession } from '@/lib/upload-sessions'
const mockBrandKnowledgeFindFirst = jest.fn()
jest.mock('@/lib/prisma', () => ({
prisma: {
brandKnowledge: {
findFirst: mockBrandKnowledgeFindFirst,
},
},
}))
describe('safe material upload cleanup protocol', () => {
let uploadRoot: string
const uploadId = 'safe-cleanup-upload-1'
beforeEach(async () => {
uploadRoot = await mkdtemp(path.join(os.tmpdir(), 'material-safe-cleanup-'))
process.env.UPLOAD_DIR = uploadRoot
process.env.USE_REDIS = 'false'
uploadSessions.clear()
mockBrandKnowledgeFindFirst.mockReset()
mockBrandKnowledgeFindFirst.mockResolvedValue(null)
})
afterEach(async () => {
uploadSessions.clear()
delete process.env.UPLOAD_DIR
delete process.env.USE_REDIS
await rm(uploadRoot, { recursive: true, force: true })
})
test('completed + valid merged is never deleted by public cancellation', async () => {
const { tempDir, mergedPath } = await createUploadDirectory(uploadId)
seedMemorySession(uploadId)
const seal = await beginUploadCompletionSeal(uploadId)
expect(seal.state).toBe('sealing')
if (seal.state !== 'sealing') throw new Error('missing seal handle')
await completeUploadCompletionSeal(seal.handle)
const result = await safelyCleanupMaterialUploadSession({
uploadId,
intent: 'cancel',
expectedOwnerId: 'user-1',
})
expect(result).toMatchObject({
success: false,
status: 'protected_completed',
code: 'UPLOAD_SESSION_COMPLETED',
httpStatus: 409,
})
await expect(readFile(mergedPath, 'utf8')).resolves.toBe('data')
await expect(stat(tempDir)).resolves.toMatchObject({})
expect(uploadSessions.has(uploadId)).toBe(true)
})
test('sealing + valid merged is retained by public and scheduled cleanup', async () => {
const { mergedPath } = await createUploadDirectory(uploadId)
seedMemorySession(uploadId)
await beginUploadCompletionSeal(uploadId)
const result = await safelyCleanupMaterialUploadSession({
uploadId,
intent: 'cancel',
expectedOwnerId: 'user-1',
})
expect(result.status).toBe('protected_sealing')
await expect(readFile(mergedPath, 'utf8')).resolves.toBe('data')
expect(uploadSessions.has(uploadId)).toBe(true)
})
test('scheduled cleanup skips sealing even before merged publication', async () => {
const { tempDir } = await createUploadDirectory(uploadId, false)
await beginUploadCompletionSeal(uploadId)
const result = await safelyCleanupMaterialUploadSession({
uploadId,
intent: 'sweep',
minAgeMs: 8 * 60 * 60 * 1000,
})
expect(result.status).toBe('protected_sealing')
await expect(stat(tempDir)).resolves.toMatchObject({})
})
test('state I/O ambiguity retains directory and session with a 503 result', async () => {
const { tempDir, mergedPath } = await createUploadDirectory(uploadId)
seedMemorySession(uploadId)
const sealDir = path.join(uploadRoot, '.locks', 'completed-upload-sessions')
await mkdir(path.join(sealDir, `${uploadId}.json`), { recursive: true })
const result = await safelyCleanupMaterialUploadSession({
uploadId,
intent: 'cancel',
expectedOwnerId: 'user-1',
})
expect(result).toMatchObject({
success: false,
status: 'unavailable',
code: 'UPLOAD_SESSION_SEAL_INVALID',
httpStatus: 503,
})
await expect(readFile(mergedPath, 'utf8')).resolves.toBe('data')
await expect(stat(tempDir)).resolves.toMatchObject({})
expect(uploadSessions.has(uploadId)).toBe(true)
})
test('cancellable session persists cancelled tombstone before deleting state and directory', async () => {
const { tempDir } = await createUploadDirectory(uploadId, false)
seedMemorySession(uploadId)
const result = await safelyCleanupMaterialUploadSession({
uploadId,
intent: 'cancel',
expectedOwnerId: 'user-1',
})
expect(result).toMatchObject({ success: true, status: 'deleted' })
expect(uploadSessions.has(uploadId)).toBe(false)
await expect(stat(tempDir)).rejects.toMatchObject({ code: 'ENOENT' })
await expect(readUploadSessionSeal(uploadId)).resolves.toMatchObject({
state: 'cancelled',
})
await expect(beginUploadCompletionSeal(uploadId)).rejects.toMatchObject({
status: 409,
code: 'UPLOAD_SESSION_CANCELLED',
})
})
test('internal consumed cleanup removes completed assembled + temp data but retains seal', async () => {
const { tempDir } = await createUploadDirectory(uploadId, false)
const assembledPath = path.join(uploadRoot, 'assembled', `${uploadId}-source.bin`)
await mkdir(path.dirname(assembledPath), { recursive: true })
await writeFile(assembledPath, 'data')
seedMemorySession(uploadId)
const seal = await beginUploadCompletionSeal(uploadId)
if (seal.state !== 'sealing') throw new Error('missing seal handle')
await completeUploadCompletionSeal(seal.handle)
const result = await cleanupConsumedUploadSession(uploadId)
expect(result).toMatchObject({ success: true, status: 'deleted' })
await expect(stat(tempDir)).rejects.toMatchObject({ code: 'ENOENT' })
await expect(stat(assembledPath)).rejects.toMatchObject({ code: 'ENOENT' })
await expect(readUploadSessionSeal(uploadId)).resolves.toMatchObject({
state: 'completed',
})
})
test('scheduled cleanup removes an abandoned completed brand-knowledge upload after 24 hours', async () => {
const abandonedId = 'abandoned-brand-knowledge-1'
const { tempDir, assembledPath } = await createAbandonedBrandKnowledgeUpload(abandonedId)
const { runCleanup } = await import('@/lib/temp-cleanup')
const result = await runCleanup()
expect(result.cleaned).toBe(1)
expect(mockBrandKnowledgeFindFirst).toHaveBeenCalled()
await expect(stat(tempDir)).rejects.toMatchObject({ code: 'ENOENT' })
await expect(stat(assembledPath)).rejects.toMatchObject({ code: 'ENOENT' })
})
test('scheduled cleanup retains an abandoned completed upload when database state is unavailable', async () => {
const abandonedId = 'abandoned-brand-knowledge-db-down'
const { tempDir, assembledPath } = await createAbandonedBrandKnowledgeUpload(abandonedId)
mockBrandKnowledgeFindFirst.mockRejectedValueOnce(new Error('database unavailable'))
const warningSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined)
const { runCleanup } = await import('@/lib/temp-cleanup')
const result = await runCleanup()
expect(result).toMatchObject({ cleaned: 0, skippedUnavailable: 1 })
await expect(stat(tempDir)).resolves.toMatchObject({})
await expect(stat(assembledPath)).resolves.toMatchObject({})
warningSpy.mockRestore()
})
async function createUploadDirectory(id: string, withMerged = true) {
const tempDir = path.join(uploadRoot, 'temp', id)
const mergedPath = path.join(tempDir, 'merged-source.bin')
await mkdir(tempDir, { recursive: true })
await writeFile(path.join(tempDir, 'meta.json'), JSON.stringify({
fileName: 'source.bin',
fileSize: 4,
totalChunks: 1,
chunkSize: 4,
userId: 'user-1',
createdAt: Date.now() - 10 * 60 * 60 * 1000,
}))
if (withMerged) {
await writeFile(mergedPath, 'data')
} else {
await writeFile(path.join(tempDir, 'chunk-0'), 'data')
}
return { tempDir, mergedPath }
}
function seedMemorySession(id: string) {
const now = Date.now() - 10 * 60 * 60 * 1000
const session: UploadSession = {
fileName: 'source.bin',
fileSize: 4,
totalChunks: 1,
chunkSize: 4,
chunks: new Map([[0, path.join(uploadRoot, 'temp', id, 'chunk-0')]]),
createdAt: now,
userId: 'user-1',
}
uploadSessions.set(id, session)
}
async function createAbandonedBrandKnowledgeUpload(id: string) {
const createdAt = Date.now() - 25 * 60 * 60 * 1000
const tempDir = path.join(uploadRoot, 'temp', id)
const assembledPath = path.join(uploadRoot, 'assembled', `${id}-source.bin`)
await mkdir(tempDir, { recursive: true })
await mkdir(path.dirname(assembledPath), { recursive: true })
await writeFile(path.join(tempDir, 'meta.json'), JSON.stringify({
fileName: 'source.bin',
fileSize: 4,
totalChunks: 1,
chunkSize: 4,
userId: 'user-1',
uploadPurpose: 'brand-knowledge',
createdAt,
}))
await writeFile(path.join(tempDir, 'chunk-0'), 'data')
await writeFile(assembledPath, 'data')
await utimes(tempDir, new Date(createdAt), new Date(createdAt))
const seal = await beginUploadCompletionSeal(id)
if (seal.state !== 'sealing') throw new Error('missing seal handle')
await completeUploadCompletionSeal(seal.handle)
const sealPath = path.join(uploadRoot, '.locks', 'completed-upload-sessions', `${id}.json`)
const storedSeal = JSON.parse(await readFile(sealPath, 'utf8'))
await writeFile(sealPath, JSON.stringify({ ...storedSeal, completedAt: createdAt }))
return { tempDir, assembledPath }
}
})