340 lines
11 KiB
TypeScript
340 lines
11 KiB
TypeScript
/**
|
|
* 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');
|
|
});
|
|
});
|