/** * S3 服务模块测试 * 测试 lib/s3.ts 中的核心功能 */ export {} // Mock AWS SDK const mockSend = jest.fn(); const mockGetSignedUrl = jest.fn(); jest.mock('@aws-sdk/client-s3', () => ({ S3Client: jest.fn().mockImplementation(() => ({ send: mockSend, })), PutObjectCommand: jest.fn().mockImplementation((params) => ({ ...params, _type: 'PutObject' })), DeleteObjectCommand: jest.fn().mockImplementation((params) => ({ ...params, _type: 'DeleteObject' })), GetObjectCommand: jest.fn().mockImplementation((params) => ({ ...params, _type: 'GetObject' })), CreateBucketCommand: jest.fn().mockImplementation((params) => ({ ...params, _type: 'CreateBucket' })), HeadBucketCommand: jest.fn().mockImplementation((params) => ({ ...params, _type: 'HeadBucket' })), PutBucketPolicyCommand: jest.fn().mockImplementation((params) => ({ ...params, _type: 'PutBucketPolicy' })), })); jest.mock('@aws-sdk/s3-request-presigner', () => ({ getSignedUrl: mockGetSignedUrl, })); // 保存原始环境变量 const originalEnv = process.env; describe('s3', () => { beforeEach(() => { jest.clearAllMocks(); // 重置模块缓存以重新加载环境变量 jest.resetModules(); // 设置测试环境变量 process.env = { ...originalEnv, S3_ENDPOINT: 'http://localhost:9000', S3_REGION: 'us-east-1', S3_ACCESS_KEY_ID: 'test-access-key', S3_SECRET_ACCESS_KEY: 'test-secret-key', S3_BUCKET: 'test-bucket', S3_PUBLIC_DOMAIN: 'http://localhost:9000/test-bucket', }; }); afterEach(() => { process.env = originalEnv; }); describe('isS3Url', () => { it('应该正确识别 HTTP URL', async () => { const { isS3Url } = await import('../s3'); expect(isS3Url('http://example.com/file.jpg')).toBe(true); expect(isS3Url('https://example.com/file.jpg')).toBe(true); }); it('应该正确识别非 URL 字符串', async () => { const { isS3Url } = await import('../s3'); expect(isS3Url('path/to/file.jpg')).toBe(false); expect(isS3Url('/absolute/path.jpg')).toBe(false); }); it('应该处理空值', async () => { const { isS3Url } = await import('../s3'); expect(isS3Url(null)).toBe(false); expect(isS3Url(undefined)).toBe(false); expect(isS3Url('')).toBe(false); }); }); describe('extractS3Key', () => { it('应该从完整 URL 提取 key', async () => { const { extractS3Key } = await import('../s3'); const key = extractS3Key('http://localhost:9000/test-bucket/path/to/file.jpg'); expect(key).toBe('test-bucket/path/to/file.jpg'); }); it('应该处理相对路径', async () => { const { extractS3Key } = await import('../s3'); expect(extractS3Key('path/to/file.jpg')).toBe('path/to/file.jpg'); }); it('应该移除前导斜杠', async () => { const { extractS3Key } = await import('../s3'); expect(extractS3Key('/path/to/file.jpg')).toBe('path/to/file.jpg'); expect(extractS3Key('///path/to/file.jpg')).toBe('path/to/file.jpg'); }); it('应该抛出错误当路径为空', async () => { const { extractS3Key } = await import('../s3'); expect(() => extractS3Key('')).toThrow('Invalid S3 path'); }); }); describe('uploadToS3', () => { beforeEach(() => { mockSend.mockResolvedValue({}); }); it('应该成功上传文件', async () => { const { uploadToS3 } = await import('../s3'); const buffer = Buffer.from('test content'); const result = await uploadToS3(buffer, 'test-file.txt', 'text/plain'); expect(result).toContain('test-file.txt'); expect(mockSend).toHaveBeenCalled(); }); it('默认上传时不应该设置 public-read ACL', async () => { const { uploadToS3 } = await import('../s3'); const buffer = Buffer.from('test content'); await uploadToS3(buffer, 'private-file.txt', 'text/plain'); const putObjectCall = mockSend.mock.calls.find((call) => call[0]?._type === 'PutObject'); expect(putObjectCall?.[0]).not.toHaveProperty('ACL'); }); it('显式开启 S3_SET_ACL 时才设置 public-read ACL', async () => { process.env.S3_SET_ACL = '1'; const { uploadToS3 } = await import('../s3'); const buffer = Buffer.from('test content'); await uploadToS3(buffer, 'public-file.txt', 'text/plain'); const putObjectCall = mockSend.mock.calls.find((call) => call[0]?._type === 'PutObject'); expect(putObjectCall?.[0].ACL).toBe('public-read'); }); it('上传失败时应该抛出错误', async () => { jest.resetModules(); // 先让 ensureBucketExists 成功,然后让 PutObject 失败 mockSend .mockResolvedValueOnce({}) // HeadBucket 成功 .mockRejectedValueOnce(new Error('Upload failed')); // PutObject 失败 const { uploadToS3 } = await import('../s3'); const buffer = Buffer.from('test content'); await expect(uploadToS3(buffer, 'test-file.txt', 'text/plain')) .rejects.toThrow('S3上传失败'); }); }); describe('uploadStreamToS3', () => { beforeEach(() => { mockSend.mockResolvedValue({}); }); it('应该支持流式上传并传递 ContentLength', async () => { const { Readable } = await import('stream'); const { uploadStreamToS3 } = await import('../s3'); const stream = Readable.from(['stream content']); const result = await uploadStreamToS3(stream, 'stream-file.mp4', 'video/mp4', 14); expect(result).toContain('stream-file.mp4'); const putObjectCall = mockSend.mock.calls.find((call) => call[0]?._type === 'PutObject'); expect(putObjectCall?.[0].Body).toBe(stream); expect(putObjectCall?.[0].ContentLength).toBe(14); expect(putObjectCall?.[0].ContentType).toBe('video/mp4'); }); }); describe('deleteFromS3', () => { it('应该成功删除文件', async () => { mockSend.mockResolvedValue({}); const { deleteFromS3 } = await import('../s3'); await deleteFromS3('http://localhost:9000/test-bucket/path/to/file.jpg'); expect(mockSend).toHaveBeenCalled(); }); it('删除失败时应该抛出错误', async () => { mockSend.mockRejectedValue(new Error('Delete failed')); const { deleteFromS3 } = await import('../s3'); await expect(deleteFromS3('path/to/file.jpg')) .rejects.toThrow('Delete failed'); }); }); describe('ensureBucketExists', () => { it('bucket 存在时不应该创建', async () => { mockSend.mockResolvedValue({}); jest.resetModules(); const { ensureBucketExists } = await import('../s3'); await ensureBucketExists(); // HeadBucket 调用 expect(mockSend).toHaveBeenCalledTimes(1); }); it('bucket 不存在时默认只创建私有 bucket', async () => { const notFoundError = { name: 'NotFound', $metadata: { httpStatusCode: 404 } }; mockSend .mockRejectedValueOnce(notFoundError) // HeadBucket 失败 .mockResolvedValueOnce({}); // CreateBucket 成功 jest.resetModules(); const { ensureBucketExists } = await import('../s3'); await ensureBucketExists(); expect(mockSend).toHaveBeenCalledTimes(2); expect(mockSend.mock.calls.some((call) => call[0]?._type === 'PutBucketPolicy')).toBe(false); }); it('bucket 不存在且显式开启 S3_SET_ACL 时才设置公开读取策略', async () => { process.env.S3_SET_ACL = '1'; const notFoundError = { name: 'NotFound', $metadata: { httpStatusCode: 404 } }; mockSend .mockRejectedValueOnce(notFoundError) .mockResolvedValueOnce({}) .mockResolvedValueOnce({}); jest.resetModules(); const { ensureBucketExists } = await import('../s3'); await ensureBucketExists(); expect(mockSend).toHaveBeenCalledTimes(3); expect(mockSend.mock.calls.some((call) => call[0]?._type === 'PutBucketPolicy')).toBe(true); }); it('没有配置 S3_BUCKET 时应该跳过', async () => { delete process.env.S3_BUCKET; jest.resetModules(); const { ensureBucketExists } = await import('../s3'); await ensureBucketExists(); expect(mockSend).not.toHaveBeenCalled(); }); }); describe('createPresignedDownloadUrl', () => { it('应该生成预签名 URL', async () => { mockGetSignedUrl.mockResolvedValue('https://signed-url.example.com/file.jpg'); const { createPresignedDownloadUrl } = await import('../s3'); const url = await createPresignedDownloadUrl('path/to/file.jpg'); expect(url).toBe('https://signed-url.example.com/file.jpg'); expect(mockGetSignedUrl).toHaveBeenCalled(); }); it('应该支持自定义过期时间', async () => { mockGetSignedUrl.mockResolvedValue('https://signed-url.example.com/file.jpg'); const { createPresignedDownloadUrl } = await import('../s3'); await createPresignedDownloadUrl('path/to/file.jpg', { expiresIn: 3600 }); expect(mockGetSignedUrl).toHaveBeenCalledWith( expect.anything(), expect.anything(), { expiresIn: 3600 } ); }); }); describe('getS3ObjectStream', () => { it('应该返回对象流信息', async () => { mockSend.mockResolvedValue({ Body: 'mock-stream', ContentLength: 1024, ContentType: 'image/jpeg', AcceptRanges: 'bytes', }); const { getS3ObjectStream } = await import('../s3'); const result = await getS3ObjectStream('path/to/file.jpg'); expect(result.body).toBe('mock-stream'); expect(result.contentLength).toBe(1024); expect(result.contentType).toBe('image/jpeg'); }); it('应该支持 Range 请求', async () => { mockSend.mockResolvedValue({ Body: 'mock-stream', ContentLength: 512, ContentRange: 'bytes 0-511/1024', }); const { getS3ObjectStream } = await import('../s3'); const result = await getS3ObjectStream('path/to/file.jpg', 'bytes=0-511'); expect(result.contentRange).toBe('bytes 0-511/1024'); }); }); }); describe('S3 环境变量处理', () => { beforeEach(() => { jest.clearAllMocks(); jest.resetModules(); }); afterEach(() => { process.env = originalEnv; }); it('应该处理 localhost 域名替换', async () => { process.env = { ...originalEnv, S3_ENDPOINT: 'http://localhost:9000', S3_REGION: 'us-east-1', S3_ACCESS_KEY_ID: 'test-key', S3_SECRET_ACCESS_KEY: 'test-secret', S3_BUCKET: 'test-bucket', S3_PUBLIC_DOMAIN: 'http://127.0.0.1:9000/test-bucket', SERVER_IP: '192.168.1.100', }; const { extractS3Key } = await import('../s3'); // 测试 extractS3Key 能正常工作 expect(extractS3Key('path/to/file.jpg')).toBe('path/to/file.jpg'); }); it('应该处理空的 S3_PUBLIC_DOMAIN', async () => { process.env = { ...originalEnv, S3_ENDPOINT: 'http://localhost:9000', S3_REGION: 'us-east-1', S3_ACCESS_KEY_ID: 'test-key', S3_SECRET_ACCESS_KEY: 'test-secret', S3_BUCKET: 'test-bucket', S3_PUBLIC_DOMAIN: '', }; const { extractS3Key } = await import('../s3'); expect(extractS3Key('path/to/file.jpg')).toBe('path/to/file.jpg'); }); });