api-server/test/m-member-01-file.integration.spec.ts
wangdl add6a1ab4c
All checks were successful
Deploy API Server / build-and-unit (push) Successful in 41s
Deploy API Server / current-integration (push) Successful in 32s
Deploy API Server / backward-compat (push) Successful in 0s
Deploy API Server / deploy (push) Successful in 1m3s
feat: LocalStorageProvider 与文件上传生命周期 Integration
- 新增 LocalStorageProvider (本地磁盘存储)
- 新增 LocalStorageController (PUT upload / GET download)
- StorageService 支持 STORAGE_DRIVER=local 切换
- 完整文件生命周期: upload-url → PUT → complete → head → delete
- 重复 complete 幂等、删除释放
- 8 项 Integration 测试全部 PASS

无需生产 COS 密钥即可本地测试文件上传
2026-06-27 13:46:18 +08:00

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();
});
}