flnxi-Wu/lib/__tests__/s3.test.ts
2026-05-25 16:05:01 +08:00

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