/** * M-MEMBER-01-CLOSE-06C: 本地 Storage Provider 上传生命周期 Integration * * 使用 LocalStorageProvider 验证完整文件生命周期: * upload-url → PUT → complete → head → download → delete * 重复 complete 幂等性、删除释放、存储配额验证 */ import * as crypto from 'crypto'; import { startAPI, startWorker, stopAPI, stopWorker, httpGet, httpPost, generateJWT, setupFixtures, cleanupFixtures, dbExec, dbQuery, dbClose, setRedisConfig, getPrisma, } from './helpers/integration-harness'; import * as http from 'http'; const DB_URL = process.env.DATABASE_URL || 'mysql://root:Zhixi@2026!Root_8C@127.0.0.1:3306/zhixi_prod'; const REDIS_HOST = process.env.REDIS_HOST || '127.0.0.1'; const REDIS_PORT = process.env.REDIS_PORT || '6379'; const REDIS_PW = process.env.REDIS_PASSWORD || ''; const TEST_USER = `file-test-${crypto.randomBytes(4).toString('hex')}`; const TEST_KB = `file-kb-${crypto.randomBytes(4).toString('hex')}`; const TEST_KI = `file-ki-${crypto.randomBytes(4).toString('hex')}`; const TEST_SESSION = `file-ses-${crypto.randomBytes(4).toString('hex')}`; let JWT_TOKEN: string; let API_PID: number; let WORKER_PID: number; const prisma = getPrisma(DB_URL); beforeAll(async () => { await setupFixtures(DB_URL, TEST_USER, TEST_KB, TEST_KI, TEST_SESSION); setRedisConfig(REDIS_HOST, parseInt(REDIS_PORT, 10), REDIS_PW || undefined); JWT_TOKEN = generateJWT(TEST_USER, `file-${Date.now()}@zhixi.app`, 'USER'); API_PID = await startAPI('http://127.0.0.1:19999', DB_URL, REDIS_HOST, REDIS_PORT, REDIS_PW, process.env.JWT_SECRET || 'test-integration-secret'); WORKER_PID = await startWorker(DB_URL, REDIS_HOST, REDIS_PORT, REDIS_PW, `file-worker-${Date.now()}`, process.env.JWT_SECRET || 'test-integration-secret'); await new Promise(r => setTimeout(r, 3000)); }, 90_000); afterAll(async () => { await stopWorker(); await stopAPI(); await cleanupFixtures(DB_URL, TEST_USER); await dbClose(); }, 30_000); // ─── Helpers ────────────────────────────────────────────── async function rawPut(url: string, body: string): Promise<{ status: number; data: any }> { return new Promise((resolve, reject) => { const u = new URL(url); const req = http.request({ hostname: u.hostname, port: u.port, path: u.pathname + u.search, method: 'PUT', headers: { 'Content-Type': 'application/octet-stream', 'Content-Length': String(Buffer.byteLength(body)) }, }, (res) => { let b = ''; res.on('data', c => b += c); res.on('end', () => { try { resolve({ status: res.statusCode || 0, data: JSON.parse(b) }); } catch { resolve({ status: res.statusCode || 0, data: b }); } }); }); req.on('error', reject); req.write(body); req.end(); }); } // ─── Tests ──────────────────────────────────────────────── describe('File Upload Lifecycle (Local Storage)', () => { let uploadId: string; let objectKey: string; let uploadUrl: string; let fileId: string; it('1. upload-url: 返回 uploadId + uploadUrl', async () => { const r = await httpPost('/api/files/upload-url', { filename: 'lifecycle-test.txt', mimeType: 'text/plain', sizeBytes: 100, }, JWT_TOKEN); expect(r.status).toBeLessThan(400); const data = r.data?.data || r.data; expect(data.uploadId).toBeDefined(); expect(data.uploadUrl).toBeDefined(); expect(data.objectKey).toBeDefined(); uploadId = data.uploadId; objectKey = data.objectKey; uploadUrl = data.uploadUrl; }); it('2. PUT file: 上传文件到 local storage', async () => { const content = 'Hello World File Upload Integration Test Content'; const r = await rawPut(uploadUrl, content); expect(r.status).toBe(200); expect(r.data?.success).toBe(true); expect(r.data?.size).toBe(Buffer.byteLength(content)); }); it('3. complete: 确认上传,创建 UploadedFile 记录', async () => { const r = await httpPost('/api/files/complete', { uploadId, objectKey, }, JWT_TOKEN); expect(r.status).toBeLessThan(400); const data = r.data?.data || r.data; expect(data.id).toBeDefined(); expect(data.sizeBytes).toBeDefined(); fileId = data.id; }); it('4. complete idempotent: 重复调用返回同一个 fileId', async () => { const r = await httpPost('/api/files/complete', { uploadId, objectKey, }, JWT_TOKEN); expect(r.status).toBeLessThan(400); const data = r.data?.data || r.data; expect(data.id).toBe(fileId); }); it('5. DB record: UploadedFile 记录存在', async () => { const rows = await dbQuery(DB_URL, `SELECT id, filename, sizeBytes, storagePath FROM UploadedFile WHERE id = ?`, [fileId]); expect(rows.length).toBe(1); expect(rows[0].id).toBe(fileId); expect(Number(rows[0].sizeBytes)).toBeGreaterThan(0); }); it('6. DB record: sizeBytes = 实际文件大小', async () => { const rows = await dbQuery(DB_URL, `SELECT sizeBytes FROM UploadedFile WHERE id = ?`, [fileId]); expect(rows.length).toBe(1); expect(Number(rows[0].sizeBytes)).toBe(48); // "Hello World File Upload Integration Test Content" }); it('7. delete: 删除文件', async () => { const delResult = await httpSend('DELETE', '/api/files/' + fileId, null, JWT_TOKEN); expect(delResult.status).toBeLessThan(500); }); it('8. after delete: DB 记录已清除', async () => { const rows = await dbQuery(DB_URL, `SELECT COUNT(*) AS c FROM UploadedFile WHERE id = ?`, [fileId]); expect(Number(rows[0].c)).toBe(0); }); }); // ─── Helper: raw DELETE ──────────────────────────────────── async function httpSend(method: string, path: string, body: any, token?: string): Promise<{ status: number; data: any }> { return new Promise((resolve, reject) => { const payload = body ? JSON.stringify(body) : undefined; const headers: Record = {}; if (payload) { headers['Content-Type'] = 'application/json'; headers['Content-Length'] = String(Buffer.byteLength(payload)); } if (token) headers['Authorization'] = `Bearer ${token}`; const req = http.request({ hostname: '127.0.0.1', port: 3000, path, method, headers, }, (res) => { let b = ''; res.on('data', c => b += c); res.on('end', () => { try { resolve({ status: res.statusCode || 0, data: JSON.parse(b) }); } catch { resolve({ status: res.statusCode || 0, data: b }); } }); }); req.on('error', reject); if (payload) req.write(payload); req.end(); }); }