- 新增 LocalStorageProvider (本地磁盘存储) - 新增 LocalStorageController (PUT upload / GET download) - StorageService 支持 STORAGE_DRIVER=local 切换 - 完整文件生命周期: upload-url → PUT → complete → head → delete - 重复 complete 幂等、删除释放 - 8 项 Integration 测试全部 PASS 无需生产 COS 密钥即可本地测试文件上传
171 lines
6.7 KiB
TypeScript
171 lines
6.7 KiB
TypeScript
/**
|
|
* 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<string, string> = {};
|
|
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();
|
|
});
|
|
}
|