updated upload unit tests for s3 image handling
This commit is contained in:
@@ -23,12 +23,88 @@ jest.mock('../../services/api');
|
||||
|
||||
const mockedApi = api as jest.Mocked<typeof api>;
|
||||
|
||||
// Mock XMLHttpRequest for uploadToS3 tests
|
||||
class MockXMLHttpRequest {
|
||||
static instances: MockXMLHttpRequest[] = [];
|
||||
|
||||
status = 200;
|
||||
readyState = 4;
|
||||
responseText = '';
|
||||
upload = {
|
||||
onprogress: null as ((e: { lengthComputable: boolean; loaded: number; total: number }) => void) | null,
|
||||
};
|
||||
onload: (() => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
|
||||
private headers: Record<string, string> = {};
|
||||
private method = '';
|
||||
private url = '';
|
||||
|
||||
constructor() {
|
||||
MockXMLHttpRequest.instances.push(this);
|
||||
}
|
||||
|
||||
open(method: string, url: string) {
|
||||
this.method = method;
|
||||
this.url = url;
|
||||
}
|
||||
|
||||
setRequestHeader(key: string, value: string) {
|
||||
this.headers[key] = value;
|
||||
}
|
||||
|
||||
send(_data: unknown) {
|
||||
// Use Promise.resolve().then for async behavior in tests
|
||||
// This allows promises to resolve without real delays
|
||||
Promise.resolve().then(() => {
|
||||
if (this.upload.onprogress) {
|
||||
this.upload.onprogress({ lengthComputable: true, loaded: 50, total: 100 });
|
||||
this.upload.onprogress({ lengthComputable: true, loaded: 100, total: 100 });
|
||||
}
|
||||
if (this.onload) {
|
||||
this.onload();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getHeaders() {
|
||||
return this.headers;
|
||||
}
|
||||
|
||||
getMethod() {
|
||||
return this.method;
|
||||
}
|
||||
|
||||
getUrl() {
|
||||
return this.url;
|
||||
}
|
||||
|
||||
static reset() {
|
||||
MockXMLHttpRequest.instances = [];
|
||||
}
|
||||
|
||||
static getLastInstance() {
|
||||
return MockXMLHttpRequest.instances[MockXMLHttpRequest.instances.length - 1];
|
||||
}
|
||||
}
|
||||
|
||||
// Store original XMLHttpRequest
|
||||
const originalXMLHttpRequest = global.XMLHttpRequest;
|
||||
|
||||
describe('Upload Service', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
MockXMLHttpRequest.reset();
|
||||
// Reset environment variables
|
||||
process.env.REACT_APP_S3_BUCKET = 'test-bucket';
|
||||
process.env.REACT_APP_AWS_REGION = 'us-east-1';
|
||||
// Mock XMLHttpRequest globally
|
||||
(global as unknown as { XMLHttpRequest: typeof MockXMLHttpRequest }).XMLHttpRequest = MockXMLHttpRequest;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore original XMLHttpRequest
|
||||
(global as unknown as { XMLHttpRequest: typeof XMLHttpRequest }).XMLHttpRequest = originalXMLHttpRequest;
|
||||
});
|
||||
|
||||
describe('getPublicImageUrl', () => {
|
||||
@@ -173,18 +249,42 @@ describe('Upload Service', () => {
|
||||
});
|
||||
|
||||
describe('uploadToS3', () => {
|
||||
// Note: XMLHttpRequest mocking is complex and can cause timeouts.
|
||||
// The uploadToS3 function is a thin wrapper around XHR.
|
||||
// Testing focuses on verifying the function signature and basic behavior.
|
||||
const mockFile = new File(['test content'], 'photo.jpg', { type: 'image/jpeg' });
|
||||
const mockUploadUrl = 'https://presigned-url.s3.amazonaws.com/items/uuid.jpg?signature=abc';
|
||||
|
||||
it('should export uploadToS3 function', () => {
|
||||
expect(typeof uploadToS3).toBe('function');
|
||||
it('should upload file successfully', async () => {
|
||||
await uploadToS3(mockFile, mockUploadUrl);
|
||||
|
||||
const instance = MockXMLHttpRequest.getLastInstance();
|
||||
expect(instance.getMethod()).toBe('PUT');
|
||||
expect(instance.getUrl()).toBe(mockUploadUrl);
|
||||
expect(instance.getHeaders()['Content-Type']).toBe('image/jpeg');
|
||||
});
|
||||
|
||||
it('should accept file, url, and options parameters', () => {
|
||||
// Verify function signature
|
||||
it('should call onProgress callback during upload', async () => {
|
||||
const onProgress = jest.fn();
|
||||
|
||||
await uploadToS3(mockFile, mockUploadUrl, { onProgress });
|
||||
|
||||
// Progress should be called at least once
|
||||
expect(onProgress).toHaveBeenCalled();
|
||||
// Should receive percentage values
|
||||
expect(onProgress).toHaveBeenCalledWith(expect.any(Number));
|
||||
});
|
||||
|
||||
it('should export uploadToS3 function with correct signature', () => {
|
||||
expect(typeof uploadToS3).toBe('function');
|
||||
// Function accepts file, url, and optional options
|
||||
expect(uploadToS3.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it('should set correct content-type header', async () => {
|
||||
const pngFile = new File(['test'], 'image.png', { type: 'image/png' });
|
||||
await uploadToS3(pngFile, mockUploadUrl);
|
||||
|
||||
const instance = MockXMLHttpRequest.getLastInstance();
|
||||
expect(instance.getHeaders()['Content-Type']).toBe('image/png');
|
||||
});
|
||||
});
|
||||
|
||||
describe('confirmUploads', () => {
|
||||
@@ -214,70 +314,230 @@ describe('Upload Service', () => {
|
||||
});
|
||||
|
||||
describe('uploadFile', () => {
|
||||
it('should call getPresignedUrl and confirmUploads in sequence', async () => {
|
||||
// Test the flow without mocking XMLHttpRequest (which is complex)
|
||||
// Instead test that the functions are called with correct parameters
|
||||
const file = new File(['test'], 'photo.jpg', { type: 'image/jpeg' });
|
||||
const presignResponse: PresignedUrlResponse = {
|
||||
uploadUrl: 'https://presigned.s3.amazonaws.com',
|
||||
key: 'items/uuid.jpg',
|
||||
publicUrl: 'https://bucket.s3.amazonaws.com/items/uuid.jpg',
|
||||
expiresAt: new Date().toISOString(),
|
||||
};
|
||||
const mockFile = new File(['test content'], 'photo.jpg', { type: 'image/jpeg' });
|
||||
const presignResponse: PresignedUrlResponse = {
|
||||
uploadUrl: 'https://presigned.s3.amazonaws.com/items/uuid.jpg',
|
||||
key: 'items/uuid.jpg',
|
||||
publicUrl: 'https://bucket.s3.amazonaws.com/items/uuid.jpg',
|
||||
expiresAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
it('should complete full upload flow successfully', async () => {
|
||||
// Mock presign response
|
||||
mockedApi.post.mockResolvedValueOnce({ data: presignResponse });
|
||||
// Mock confirm response
|
||||
mockedApi.post.mockResolvedValueOnce({
|
||||
data: { confirmed: [presignResponse.key], total: 1 },
|
||||
});
|
||||
|
||||
// Just test getPresignedUrl is called correctly
|
||||
await getPresignedUrl('item', file);
|
||||
const result = await uploadFile('item', mockFile);
|
||||
|
||||
expect(result).toEqual({
|
||||
key: presignResponse.key,
|
||||
publicUrl: presignResponse.publicUrl,
|
||||
});
|
||||
|
||||
// Verify presign was called
|
||||
expect(mockedApi.post).toHaveBeenCalledWith('/upload/presign', {
|
||||
uploadType: 'item',
|
||||
contentType: 'image/jpeg',
|
||||
fileName: 'photo.jpg',
|
||||
fileSize: file.size,
|
||||
fileSize: mockFile.size,
|
||||
});
|
||||
|
||||
// Verify confirm was called
|
||||
expect(mockedApi.post).toHaveBeenCalledWith('/upload/confirm', {
|
||||
keys: [presignResponse.key],
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw error when upload verification fails', async () => {
|
||||
mockedApi.post.mockResolvedValueOnce({ data: presignResponse });
|
||||
// Mock confirm returning empty confirmed array
|
||||
mockedApi.post.mockResolvedValueOnce({
|
||||
data: { confirmed: [], total: 1 },
|
||||
});
|
||||
|
||||
await expect(uploadFile('item', mockFile)).rejects.toThrow('Upload verification failed');
|
||||
});
|
||||
|
||||
it('should pass onProgress to uploadToS3', async () => {
|
||||
const onProgress = jest.fn();
|
||||
|
||||
mockedApi.post.mockResolvedValueOnce({ data: presignResponse });
|
||||
mockedApi.post.mockResolvedValueOnce({
|
||||
data: { confirmed: [presignResponse.key], total: 1 },
|
||||
});
|
||||
|
||||
await uploadFile('item', mockFile, { onProgress });
|
||||
|
||||
// onProgress should have been called during XHR upload
|
||||
expect(onProgress).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should work with different upload types', async () => {
|
||||
const messagePresignResponse = {
|
||||
...presignResponse,
|
||||
key: 'messages/uuid.jpg',
|
||||
publicUrl: null, // Messages are private
|
||||
};
|
||||
|
||||
mockedApi.post.mockResolvedValueOnce({ data: messagePresignResponse });
|
||||
mockedApi.post.mockResolvedValueOnce({
|
||||
data: { confirmed: [messagePresignResponse.key], total: 1 },
|
||||
});
|
||||
|
||||
const result = await uploadFile('message', mockFile);
|
||||
|
||||
expect(result.key).toBe('messages/uuid.jpg');
|
||||
expect(mockedApi.post).toHaveBeenCalledWith('/upload/presign', expect.objectContaining({
|
||||
uploadType: 'message',
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
describe('uploadFiles', () => {
|
||||
const mockFiles = [
|
||||
new File(['test1'], 'photo1.jpg', { type: 'image/jpeg' }),
|
||||
new File(['test2'], 'photo2.png', { type: 'image/png' }),
|
||||
];
|
||||
|
||||
const presignResponses: PresignedUrlResponse[] = [
|
||||
{
|
||||
uploadUrl: 'https://presigned1.s3.amazonaws.com/items/uuid1.jpg',
|
||||
key: 'items/uuid1.jpg',
|
||||
publicUrl: 'https://bucket.s3.amazonaws.com/items/uuid1.jpg',
|
||||
expiresAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
uploadUrl: 'https://presigned2.s3.amazonaws.com/items/uuid2.png',
|
||||
key: 'items/uuid2.png',
|
||||
publicUrl: 'https://bucket.s3.amazonaws.com/items/uuid2.png',
|
||||
expiresAt: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
|
||||
it('should return empty array for empty files array', async () => {
|
||||
const result = await uploadFiles('item', []);
|
||||
expect(result).toEqual([]);
|
||||
expect(mockedApi.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call getPresignedUrls with correct parameters', async () => {
|
||||
const files = [
|
||||
new File(['test1'], 'photo1.jpg', { type: 'image/jpeg' }),
|
||||
new File(['test2'], 'photo2.png', { type: 'image/png' }),
|
||||
];
|
||||
|
||||
const presignResponses: PresignedUrlResponse[] = [
|
||||
{
|
||||
uploadUrl: 'https://presigned1.s3.amazonaws.com',
|
||||
key: 'items/uuid1.jpg',
|
||||
publicUrl: 'https://bucket.s3.amazonaws.com/items/uuid1.jpg',
|
||||
expiresAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
uploadUrl: 'https://presigned2.s3.amazonaws.com',
|
||||
key: 'items/uuid2.png',
|
||||
publicUrl: 'https://bucket.s3.amazonaws.com/items/uuid2.png',
|
||||
expiresAt: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
|
||||
it('should complete full batch upload flow successfully', async () => {
|
||||
mockedApi.post.mockResolvedValueOnce({ data: { uploads: presignResponses } });
|
||||
mockedApi.post.mockResolvedValueOnce({
|
||||
data: {
|
||||
confirmed: presignResponses.map((p) => p.key),
|
||||
total: 2,
|
||||
},
|
||||
});
|
||||
|
||||
await getPresignedUrls('item', files);
|
||||
const result = await uploadFiles('item', mockFiles);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]).toEqual({
|
||||
key: 'items/uuid1.jpg',
|
||||
publicUrl: 'https://bucket.s3.amazonaws.com/items/uuid1.jpg',
|
||||
});
|
||||
expect(result[1]).toEqual({
|
||||
key: 'items/uuid2.png',
|
||||
publicUrl: 'https://bucket.s3.amazonaws.com/items/uuid2.png',
|
||||
});
|
||||
|
||||
// Verify batch presign was called
|
||||
expect(mockedApi.post).toHaveBeenCalledWith('/upload/presign-batch', {
|
||||
uploadType: 'item',
|
||||
files: [
|
||||
{ contentType: 'image/jpeg', fileName: 'photo1.jpg', fileSize: files[0].size },
|
||||
{ contentType: 'image/png', fileName: 'photo2.png', fileSize: files[1].size },
|
||||
{ contentType: 'image/jpeg', fileName: 'photo1.jpg', fileSize: mockFiles[0].size },
|
||||
{ contentType: 'image/png', fileName: 'photo2.png', fileSize: mockFiles[1].size },
|
||||
],
|
||||
});
|
||||
|
||||
// Verify confirm was called with all keys
|
||||
expect(mockedApi.post).toHaveBeenCalledWith('/upload/confirm', {
|
||||
keys: ['items/uuid1.jpg', 'items/uuid2.png'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should filter out unconfirmed uploads', async () => {
|
||||
mockedApi.post.mockResolvedValueOnce({ data: { uploads: presignResponses } });
|
||||
// Only first file confirmed
|
||||
mockedApi.post.mockResolvedValueOnce({
|
||||
data: {
|
||||
confirmed: ['items/uuid1.jpg'],
|
||||
total: 2,
|
||||
},
|
||||
});
|
||||
|
||||
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation();
|
||||
|
||||
const result = await uploadFiles('item', mockFiles);
|
||||
|
||||
// Only confirmed uploads should be returned
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].key).toBe('items/uuid1.jpg');
|
||||
|
||||
// Should log warning about failed verification
|
||||
expect(consoleSpy).toHaveBeenCalledWith('1 uploads failed verification');
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should handle all uploads failing verification', async () => {
|
||||
mockedApi.post.mockResolvedValueOnce({ data: { uploads: presignResponses } });
|
||||
mockedApi.post.mockResolvedValueOnce({
|
||||
data: {
|
||||
confirmed: [],
|
||||
total: 2,
|
||||
},
|
||||
});
|
||||
|
||||
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation();
|
||||
|
||||
const result = await uploadFiles('item', mockFiles);
|
||||
|
||||
expect(result).toHaveLength(0);
|
||||
expect(consoleSpy).toHaveBeenCalledWith('2 uploads failed verification');
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should upload all files in parallel', async () => {
|
||||
mockedApi.post.mockResolvedValueOnce({ data: { uploads: presignResponses } });
|
||||
mockedApi.post.mockResolvedValueOnce({
|
||||
data: {
|
||||
confirmed: presignResponses.map((p) => p.key),
|
||||
total: 2,
|
||||
},
|
||||
});
|
||||
|
||||
await uploadFiles('item', mockFiles);
|
||||
|
||||
// Should have created 2 XHR instances for parallel uploads
|
||||
expect(MockXMLHttpRequest.instances.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should work with different upload types', async () => {
|
||||
const forumResponses = presignResponses.map((r) => ({
|
||||
...r,
|
||||
key: r.key.replace('items/', 'forum/'),
|
||||
publicUrl: r.publicUrl.replace('items/', 'forum/'),
|
||||
}));
|
||||
|
||||
mockedApi.post.mockResolvedValueOnce({ data: { uploads: forumResponses } });
|
||||
mockedApi.post.mockResolvedValueOnce({
|
||||
data: {
|
||||
confirmed: forumResponses.map((p) => p.key),
|
||||
total: 2,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await uploadFiles('forum', mockFiles);
|
||||
|
||||
expect(result[0].key).toBe('forum/uuid1.jpg');
|
||||
expect(mockedApi.post).toHaveBeenCalledWith('/upload/presign-batch', expect.objectContaining({
|
||||
uploadType: 'forum',
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user