Skip to content

Commit

Permalink
♻️ refactor: refactor client mode upload to match server mode (#5111)
Browse files Browse the repository at this point in the history
* ♻️ refactor: refactor upload method

* fix tests

* ✅ test: add tests

* 🐛 fix: fix image
  • Loading branch information
arvinxx authored Dec 21, 2024
1 parent 9c51c68 commit 0361ced
Show file tree
Hide file tree
Showing 11 changed files with 473 additions and 107 deletions.
20 changes: 17 additions & 3 deletions src/database/_deprecated/models/file.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { DBModel } from '@/database/_deprecated/core/types/db';
import { DB_File, DB_FileSchema } from '@/database/_deprecated/schemas/files';
import { clientS3Storage } from '@/services/file/ClientS3';
import { nanoid } from '@/utils/uuid';

import { BaseModel } from '../core';
Expand All @@ -20,9 +21,15 @@ class _FileModel extends BaseModel<'files'> {
if (!item) return;

// arrayBuffer to url
const base64 = Buffer.from(item.data!).toString('base64');

return { ...item, url: `data:${item.fileType};base64,${base64}` };
let base64;
if (!item.data) {
const hash = (item.url as string).replace('client-s3://', '');
base64 = await this.getBase64ByFileHash(hash);
} else {
base64 = Buffer.from(item.data).toString('base64');
}

return { ...item, base64, url: `data:${item.fileType};base64,${base64}` };
}

async delete(id: string) {
Expand All @@ -32,6 +39,13 @@ class _FileModel extends BaseModel<'files'> {
async clear() {
return this.table.clear();
}

private async getBase64ByFileHash(hash: string) {
const fileItem = await clientS3Storage.getObject(hash);
if (!fileItem) throw new Error('file not found');

return Buffer.from(await fileItem.arrayBuffer()).toString('base64');
}
}

export const FileModel = new _FileModel();
4 changes: 1 addition & 3 deletions src/server/routers/lambda/file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,7 @@ export const fileRouter = router({
}),

createFile: fileProcedure
.input(
UploadFileSchema.omit({ data: true, saveMode: true, url: true }).extend({ url: z.string() }),
)
.input(UploadFileSchema.omit({ url: true }).extend({ url: z.string() }))
.mutation(async ({ ctx, input }) => {
const { isExist } = await ctx.fileModel.checkHash(input.hash!);

Expand Down
175 changes: 175 additions & 0 deletions src/services/__tests__/upload.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';

import { fileEnv } from '@/config/file';
import { edgeClient } from '@/libs/trpc/client';
import { API_ENDPOINTS } from '@/services/_url';
import { clientS3Storage } from '@/services/file/ClientS3';

import { UPLOAD_NETWORK_ERROR, uploadService } from '../upload';

// Mock dependencies
vi.mock('@/libs/trpc/client', () => ({
edgeClient: {
upload: {
createS3PreSignedUrl: {
mutate: vi.fn(),
},
},
},
}));

vi.mock('@/services/file/ClientS3', () => ({
clientS3Storage: {
putObject: vi.fn(),
},
}));

vi.mock('@/utils/uuid', () => ({
uuid: () => 'mock-uuid',
}));

describe('UploadService', () => {
const mockFile = new File(['test'], 'test.png', { type: 'image/png' });
const mockPreSignUrl = 'https://example.com/presign';

beforeEach(() => {
vi.clearAllMocks();
// Mock Date.now
vi.spyOn(Date, 'now').mockImplementation(() => 3600000); // 1 hour in milliseconds
});

describe('uploadWithProgress', () => {
beforeEach(() => {
// Mock XMLHttpRequest
const xhrMock = {
upload: {
addEventListener: vi.fn(),
},
open: vi.fn(),
send: vi.fn(),
setRequestHeader: vi.fn(),
addEventListener: vi.fn(),
status: 200,
};
global.XMLHttpRequest = vi.fn(() => xhrMock) as any;

// Mock createS3PreSignedUrl
(edgeClient.upload.createS3PreSignedUrl.mutate as any).mockResolvedValue(mockPreSignUrl);
});

it('should upload file successfully with progress', async () => {
const onProgress = vi.fn();
const xhr = new XMLHttpRequest();

// Simulate successful upload
vi.spyOn(xhr, 'addEventListener').mockImplementation((event, handler) => {
if (event === 'load') {
// @ts-ignore
handler({ target: { status: 200 } });
}
});

const result = await uploadService.uploadWithProgress(mockFile, { onProgress });

expect(result).toEqual({
date: '1',
dirname: `${fileEnv.NEXT_PUBLIC_S3_FILE_PATH}/1`,
filename: 'mock-uuid.png',
path: `${fileEnv.NEXT_PUBLIC_S3_FILE_PATH}/1/mock-uuid.png`,
});
});

it('should handle network error', async () => {
const xhr = new XMLHttpRequest();

// Simulate network error
vi.spyOn(xhr, 'addEventListener').mockImplementation((event, handler) => {
if (event === 'error') {
Object.assign(xhr, { status: 0 });
// @ts-ignore
handler({});
}
});

await expect(uploadService.uploadWithProgress(mockFile, {})).rejects.toBe(
UPLOAD_NETWORK_ERROR,
);
});

it('should handle upload error', async () => {
const xhr = new XMLHttpRequest();

// Simulate upload error
vi.spyOn(xhr, 'addEventListener').mockImplementation((event, handler) => {
if (event === 'load') {
Object.assign(xhr, { status: 400, statusText: 'Bad Request' });

// @ts-ignore
handler({});
}
});

await expect(uploadService.uploadWithProgress(mockFile, {})).rejects.toBe('Bad Request');
});
});

describe('uploadToClientS3', () => {
it('should upload file to client S3 successfully', async () => {
const hash = 'test-hash';
const expectedResult = {
date: '1',
dirname: '',
filename: mockFile.name,
path: `client-s3://${hash}`,
};

(clientS3Storage.putObject as any).mockResolvedValue(undefined);

const result = await uploadService.uploadToClientS3(hash, mockFile);

expect(clientS3Storage.putObject).toHaveBeenCalledWith(hash, mockFile);
expect(result).toEqual(expectedResult);
});
});

describe('getImageFileByUrlWithCORS', () => {
beforeEach(() => {
global.fetch = vi.fn();
});

it('should fetch and create file from URL', async () => {
const url = 'https://example.com/image.png';
const filename = 'test.png';
const mockArrayBuffer = new ArrayBuffer(8);

(global.fetch as any).mockResolvedValue({
arrayBuffer: () => Promise.resolve(mockArrayBuffer),
});

const result = await uploadService.getImageFileByUrlWithCORS(url, filename);

expect(global.fetch).toHaveBeenCalledWith(API_ENDPOINTS.proxy, {
body: url,
method: 'POST',
});
expect(result).toBeInstanceOf(File);
expect(result.name).toBe(filename);
expect(result.type).toBe('image/png');
});

it('should handle custom file type', async () => {
const url = 'https://example.com/image.jpg';
const filename = 'test.jpg';
const fileType = 'image/jpeg';
const mockArrayBuffer = new ArrayBuffer(8);

(global.fetch as any).mockResolvedValue({
arrayBuffer: () => Promise.resolve(mockArrayBuffer),
});

const result = await uploadService.getImageFileByUrlWithCORS(url, filename, fileType);

expect(result.type).toBe(fileType);
});
});
});
115 changes: 115 additions & 0 deletions src/services/file/ClientS3/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { createStore, del, get, set } from 'idb-keyval';
import { beforeEach, describe, expect, it, vi } from 'vitest';

import { BrowserS3Storage } from './index';

// Mock idb-keyval
vi.mock('idb-keyval', () => ({
createStore: vi.fn(),
set: vi.fn(),
get: vi.fn(),
del: vi.fn(),
}));

let storage: BrowserS3Storage;
let mockStore = {};

beforeEach(() => {
// Reset all mocks before each test
vi.clearAllMocks();
mockStore = {};
(createStore as any).mockReturnValue(mockStore);
storage = new BrowserS3Storage();
});

describe('BrowserS3Storage', () => {
describe('constructor', () => {
it('should create store when in browser environment', () => {
expect(createStore).toHaveBeenCalledWith('lobechat-local-s3', 'objects');
});
});

describe('putObject', () => {
it('should successfully put a file object', async () => {
const mockFile = new File(['test content'], 'test.txt', { type: 'text/plain' });
const mockArrayBuffer = new ArrayBuffer(8);
vi.spyOn(mockFile, 'arrayBuffer').mockResolvedValue(mockArrayBuffer);
(set as any).mockResolvedValue(undefined);

await storage.putObject('1-test-key', mockFile);

expect(set).toHaveBeenCalledWith(
'1-test-key',
{
data: mockArrayBuffer,
name: 'test.txt',
type: 'text/plain',
},
mockStore,
);
});

it('should throw error when put operation fails', async () => {
const mockFile = new File(['test content'], 'test.txt', { type: 'text/plain' });
const mockError = new Error('Storage error');
(set as any).mockRejectedValue(mockError);

await expect(storage.putObject('test-key', mockFile)).rejects.toThrow(
'Failed to put file test.txt: Storage error',
);
});
});

describe('getObject', () => {
it('should successfully get a file object', async () => {
const mockData = {
data: new ArrayBuffer(8),
name: 'test.txt',
type: 'text/plain',
};
(get as any).mockResolvedValue(mockData);

const result = await storage.getObject('test-key');

expect(result).toBeInstanceOf(File);
expect(result?.name).toBe('test.txt');
expect(result?.type).toBe('text/plain');
});

it('should return undefined when file not found', async () => {
(get as any).mockResolvedValue(undefined);

const result = await storage.getObject('test-key');

expect(result).toBeUndefined();
});

it('should throw error when get operation fails', async () => {
const mockError = new Error('Storage error');
(get as any).mockRejectedValue(mockError);

await expect(storage.getObject('test-key')).rejects.toThrow(
'Failed to get object (key=test-key): Storage error',
);
});
});

describe('deleteObject', () => {
it('should successfully delete a file object', async () => {
(del as any).mockResolvedValue(undefined);

await storage.deleteObject('test-key2');

expect(del).toHaveBeenCalledWith('test-key2', {});
});

it('should throw error when delete operation fails', async () => {
const mockError = new Error('Storage error');
(del as any).mockRejectedValue(mockError);

await expect(storage.deleteObject('test-key')).rejects.toThrow(
'Failed to delete object (key=test-key): Storage error',
);
});
});
});
Loading

0 comments on commit 0361ced

Please sign in to comment.