@lobehub/lobehub 2.0.0-next.98 → 2.0.0-next.99
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +25 -0
- package/apps/desktop/src/main/services/__tests__/fileSrv.test.ts +603 -0
- package/changelog/v1.json +9 -0
- package/codecov.yml +1 -0
- package/locales/ar/plugin.json +34 -22
- package/locales/ar/tool.json +8 -0
- package/locales/bg-BG/plugin.json +34 -22
- package/locales/bg-BG/tool.json +8 -0
- package/locales/de-DE/plugin.json +34 -22
- package/locales/de-DE/tool.json +8 -0
- package/locales/en-US/plugin.json +34 -22
- package/locales/en-US/tool.json +8 -0
- package/locales/es-ES/plugin.json +34 -22
- package/locales/es-ES/tool.json +8 -0
- package/locales/fa-IR/plugin.json +34 -22
- package/locales/fa-IR/tool.json +8 -0
- package/locales/fr-FR/plugin.json +34 -22
- package/locales/fr-FR/tool.json +8 -0
- package/locales/it-IT/plugin.json +34 -22
- package/locales/it-IT/tool.json +8 -0
- package/locales/ja-JP/plugin.json +34 -22
- package/locales/ja-JP/tool.json +8 -0
- package/locales/ko-KR/plugin.json +34 -22
- package/locales/ko-KR/tool.json +8 -0
- package/locales/nl-NL/plugin.json +34 -22
- package/locales/nl-NL/tool.json +8 -0
- package/locales/pl-PL/plugin.json +34 -22
- package/locales/pl-PL/tool.json +8 -0
- package/locales/pt-BR/plugin.json +34 -22
- package/locales/pt-BR/tool.json +8 -0
- package/locales/ru-RU/plugin.json +34 -22
- package/locales/ru-RU/tool.json +8 -0
- package/locales/tr-TR/plugin.json +34 -22
- package/locales/tr-TR/tool.json +8 -0
- package/locales/vi-VN/plugin.json +34 -22
- package/locales/vi-VN/tool.json +8 -0
- package/locales/zh-CN/plugin.json +34 -22
- package/locales/zh-CN/tool.json +8 -0
- package/locales/zh-TW/plugin.json +34 -22
- package/locales/zh-TW/tool.json +8 -0
- package/package.json +1 -1
- package/packages/database/src/models/__tests__/document.test.ts +149 -0
- package/packages/database/src/models/chunk.ts +3 -1
- package/packages/database/src/models/document.ts +8 -2
- package/packages/prompts/src/prompts/knowledgeBaseQA/__snapshots__/formatFileContents.test.ts.snap +75 -0
- package/packages/prompts/src/prompts/knowledgeBaseQA/__snapshots__/formatNoSearchResults.test.ts.snap +45 -0
- package/packages/prompts/src/prompts/knowledgeBaseQA/__snapshots__/formatSearchResults.test.ts.snap +82 -0
- package/packages/prompts/src/prompts/knowledgeBaseQA/formatFileContents.test.ts +118 -0
- package/packages/prompts/src/prompts/knowledgeBaseQA/formatFileContents.ts +31 -0
- package/packages/prompts/src/prompts/knowledgeBaseQA/formatNoSearchResults.test.ts +25 -0
- package/packages/prompts/src/prompts/knowledgeBaseQA/formatNoSearchResults.ts +13 -0
- package/packages/prompts/src/prompts/knowledgeBaseQA/formatSearchResults.test.ts +191 -0
- package/packages/prompts/src/prompts/knowledgeBaseQA/formatSearchResults.ts +50 -0
- package/packages/prompts/src/prompts/knowledgeBaseQA/index.ts +6 -0
- package/packages/types/src/rag.ts +13 -4
- package/src/features/ChatInput/ActionBar/Token/TokenTag.tsx +2 -2
- package/src/features/ChatInput/ActionBar/Token/TokenTagForGroupChat.tsx +2 -2
- package/src/features/ChatList/Messages/Group/Tool/Inspector/ToolTitle.tsx +5 -23
- package/src/features/ChatList/Messages/Tool/Inspector/ToolTitle.tsx +5 -25
- package/src/features/PluginsUI/Render/BuiltinType/index.tsx +1 -1
- package/src/helpers/toolEngineering/index.test.ts +3 -3
- package/src/helpers/toolEngineering/index.ts +17 -4
- package/src/libs/trpc/client/lambda.ts +0 -6
- package/src/locales/default/plugin.ts +34 -22
- package/src/locales/default/tool.ts +13 -5
- package/src/server/routers/lambda/chunk.ts +168 -41
- package/src/services/chat/chat.test.ts +3 -3
- package/src/services/chat/index.ts +2 -2
- package/src/services/rag.ts +6 -2
- package/src/store/chat/slices/aiChat/actions/__tests__/conversationLifecycle.test.ts +11 -0
- package/src/store/chat/slices/aiChat/actions/__tests__/rag.test.ts +0 -87
- package/src/store/chat/slices/aiChat/actions/__tests__/streamingExecutor.test.ts +2 -69
- package/src/store/chat/slices/aiChat/actions/conversationLifecycle.ts +0 -2
- package/src/store/chat/slices/aiChat/actions/rag.ts +0 -47
- package/src/store/chat/slices/aiChat/actions/streamingExecutor.ts +8 -69
- package/src/store/chat/slices/builtinTool/actions/index.ts +4 -1
- package/src/store/chat/slices/builtinTool/actions/knowledgeBase.ts +174 -0
- package/src/store/chat/slices/operation/types.ts +1 -0
- package/src/store/chat/slices/thread/action.test.ts +0 -1
- package/src/store/chat/slices/thread/action.ts +0 -1
- package/src/tools/executionRuntimes.ts +3 -0
- package/src/tools/identifiers.ts +13 -0
- package/src/tools/index.ts +7 -0
- package/src/tools/knowledge-base/ExecutionRuntime/index.ts +96 -0
- package/src/tools/knowledge-base/Render/ReadKnowledge/FileCard.tsx +135 -0
- package/src/tools/knowledge-base/Render/ReadKnowledge/index.tsx +27 -0
- package/src/tools/knowledge-base/Render/SearchKnowledgeBase/Item/index.tsx +54 -0
- package/src/tools/knowledge-base/Render/SearchKnowledgeBase/Item/style.ts +51 -0
- package/src/tools/knowledge-base/Render/SearchKnowledgeBase/index.tsx +23 -0
- package/src/tools/knowledge-base/Render/index.ts +7 -0
- package/src/tools/knowledge-base/index.ts +64 -0
- package/src/tools/knowledge-base/systemRole.ts +102 -0
- package/src/tools/knowledge-base/type.ts +25 -0
- package/src/tools/local-system/Intervention/WriteFile/index.tsx +1 -1
- package/src/tools/renders.ts +4 -0
- package/src/store/chat/agents/createToolEngine.ts +0 -22
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,31 @@
|
|
|
2
2
|
|
|
3
3
|
# Changelog
|
|
4
4
|
|
|
5
|
+
## [Version 2.0.0-next.99](https://github.com/lobehub/lobe-chat/compare/v2.0.0-next.98...v2.0.0-next.99)
|
|
6
|
+
|
|
7
|
+
<sup>Released on **2025-11-21**</sup>
|
|
8
|
+
|
|
9
|
+
#### ✨ Features
|
|
10
|
+
|
|
11
|
+
- **misc**: Refactor to use kb search tool.
|
|
12
|
+
|
|
13
|
+
<br/>
|
|
14
|
+
|
|
15
|
+
<details>
|
|
16
|
+
<summary><kbd>Improvements and Fixes</kbd></summary>
|
|
17
|
+
|
|
18
|
+
#### What's improved
|
|
19
|
+
|
|
20
|
+
- **misc**: Refactor to use kb search tool, closes [#10340](https://github.com/lobehub/lobe-chat/issues/10340) ([291ff3c](https://github.com/lobehub/lobe-chat/commit/291ff3c))
|
|
21
|
+
|
|
22
|
+
</details>
|
|
23
|
+
|
|
24
|
+
<div align="right">
|
|
25
|
+
|
|
26
|
+
[](#readme-top)
|
|
27
|
+
|
|
28
|
+
</div>
|
|
29
|
+
|
|
5
30
|
## [Version 2.0.0-next.98](https://github.com/lobehub/lobe-chat/compare/v2.0.0-next.97...v2.0.0-next.98)
|
|
6
31
|
|
|
7
32
|
<sup>Released on **2025-11-21**</sup>
|
|
@@ -0,0 +1,603 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import type { App } from '@/core/App';
|
|
4
|
+
|
|
5
|
+
import FileService, { FileNotFoundError } from '../fileSrv';
|
|
6
|
+
|
|
7
|
+
// Mock electron
|
|
8
|
+
vi.mock('electron', () => ({
|
|
9
|
+
app: {
|
|
10
|
+
getAppPath: vi.fn(() => '/mock/app/path'),
|
|
11
|
+
getPath: vi.fn(() => '/mock/user/data'),
|
|
12
|
+
},
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
// Mock constants that depend on electron
|
|
16
|
+
vi.mock('@/const/dir', () => ({
|
|
17
|
+
FILE_STORAGE_DIR: 'file-storage',
|
|
18
|
+
LOCAL_STORAGE_URL_PREFIX: '/lobe-desktop-file',
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
// Mock logger
|
|
22
|
+
vi.mock('@/utils/logger', () => ({
|
|
23
|
+
createLogger: () => ({
|
|
24
|
+
debug: vi.fn(),
|
|
25
|
+
info: vi.fn(),
|
|
26
|
+
warn: vi.fn(),
|
|
27
|
+
error: vi.fn(),
|
|
28
|
+
}),
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
// Mock file-system utilities
|
|
32
|
+
vi.mock('@/utils/file-system', () => ({
|
|
33
|
+
makeSureDirExist: vi.fn(),
|
|
34
|
+
}));
|
|
35
|
+
|
|
36
|
+
// Mock node:fs/promises
|
|
37
|
+
vi.mock('node:fs/promises', () => ({
|
|
38
|
+
writeFile: vi.fn(),
|
|
39
|
+
readFile: vi.fn(),
|
|
40
|
+
access: vi.fn(),
|
|
41
|
+
}));
|
|
42
|
+
|
|
43
|
+
// Mock node:fs
|
|
44
|
+
vi.mock('node:fs', () => ({
|
|
45
|
+
default: {
|
|
46
|
+
constants: { F_OK: 0 },
|
|
47
|
+
promises: { access: vi.fn() },
|
|
48
|
+
readFile: vi.fn(),
|
|
49
|
+
unlink: vi.fn(),
|
|
50
|
+
},
|
|
51
|
+
constants: { F_OK: 0 },
|
|
52
|
+
promises: { access: vi.fn() },
|
|
53
|
+
readFile: vi.fn(),
|
|
54
|
+
unlink: vi.fn(),
|
|
55
|
+
}));
|
|
56
|
+
|
|
57
|
+
// Mock node:util promisify
|
|
58
|
+
vi.mock('node:util', () => ({
|
|
59
|
+
promisify: vi.fn((fn: any) => {
|
|
60
|
+
return vi.fn(async (...args: any[]) => {
|
|
61
|
+
return new Promise((resolve, reject) => {
|
|
62
|
+
fn(...args, (err: any, data: any) => {
|
|
63
|
+
if (err) reject(err);
|
|
64
|
+
else resolve(data);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
}),
|
|
69
|
+
}));
|
|
70
|
+
|
|
71
|
+
describe('FileService', () => {
|
|
72
|
+
let fileService: FileService;
|
|
73
|
+
let mockApp: App;
|
|
74
|
+
let mockMakeSureDirExist: any;
|
|
75
|
+
let mockWriteFile: any;
|
|
76
|
+
let mockReadFile: any;
|
|
77
|
+
let mockAccess: any;
|
|
78
|
+
let mockFsReadFile: any;
|
|
79
|
+
let mockFsUnlink: any;
|
|
80
|
+
|
|
81
|
+
beforeEach(async () => {
|
|
82
|
+
vi.clearAllMocks();
|
|
83
|
+
|
|
84
|
+
// Setup mock app
|
|
85
|
+
mockApp = {
|
|
86
|
+
appStoragePath: '/mock/app/storage',
|
|
87
|
+
staticFileServerManager: {
|
|
88
|
+
getFileServerDomain: vi.fn().mockReturnValue('http://localhost:3000'),
|
|
89
|
+
},
|
|
90
|
+
} as unknown as App;
|
|
91
|
+
|
|
92
|
+
// Import mocks
|
|
93
|
+
mockMakeSureDirExist = (await import('@/utils/file-system')).makeSureDirExist;
|
|
94
|
+
const fsPromises = await import('node:fs/promises');
|
|
95
|
+
mockWriteFile = fsPromises.writeFile;
|
|
96
|
+
mockReadFile = fsPromises.readFile;
|
|
97
|
+
mockAccess = fsPromises.access;
|
|
98
|
+
|
|
99
|
+
const fs = await import('node:fs');
|
|
100
|
+
mockFsReadFile = fs.readFile;
|
|
101
|
+
mockFsUnlink = fs.unlink;
|
|
102
|
+
|
|
103
|
+
fileService = new FileService(mockApp);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe('uploadFile', () => {
|
|
107
|
+
it('should upload file with ArrayBuffer content successfully', async () => {
|
|
108
|
+
const content = new ArrayBuffer(10);
|
|
109
|
+
const params = {
|
|
110
|
+
content,
|
|
111
|
+
filename: 'test.png',
|
|
112
|
+
hash: 'abc123',
|
|
113
|
+
path: 'user_uploads/images/test.png',
|
|
114
|
+
type: 'image/png',
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
mockWriteFile.mockResolvedValue(undefined);
|
|
118
|
+
|
|
119
|
+
const result = await fileService.uploadFile(params);
|
|
120
|
+
|
|
121
|
+
expect(result.success).toBe(true);
|
|
122
|
+
expect(result.metadata.filename).toBe('test.png');
|
|
123
|
+
expect(result.metadata.dirname).toBe('user_uploads/images');
|
|
124
|
+
expect(result.metadata.path).toBe('desktop://user_uploads/images/test.png');
|
|
125
|
+
expect(mockMakeSureDirExist).toHaveBeenCalled();
|
|
126
|
+
expect(mockWriteFile).toHaveBeenCalledTimes(2); // file + metadata
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('should upload file with Base64 string content successfully', async () => {
|
|
130
|
+
const base64Content = Buffer.from('test content').toString('base64');
|
|
131
|
+
const params = {
|
|
132
|
+
content: base64Content,
|
|
133
|
+
filename: 'test.txt',
|
|
134
|
+
hash: 'def456',
|
|
135
|
+
path: 'documents/test.txt',
|
|
136
|
+
type: 'text/plain',
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
mockWriteFile.mockResolvedValue(undefined);
|
|
140
|
+
|
|
141
|
+
const result = await fileService.uploadFile(params);
|
|
142
|
+
|
|
143
|
+
expect(result.success).toBe(true);
|
|
144
|
+
expect(result.metadata.filename).toBe('test.txt');
|
|
145
|
+
expect(result.metadata.path).toBe('desktop://documents/test.txt');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('should create metadata file with correct structure', async () => {
|
|
149
|
+
const content = new ArrayBuffer(100);
|
|
150
|
+
const params = {
|
|
151
|
+
content,
|
|
152
|
+
filename: 'image.jpg',
|
|
153
|
+
hash: 'xyz789',
|
|
154
|
+
path: 'photos/image.jpg',
|
|
155
|
+
type: 'image/jpeg',
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
let metadataContent: string = '';
|
|
159
|
+
mockWriteFile.mockImplementation(async (path: any, data: any) => {
|
|
160
|
+
if (path.toString().endsWith('.meta')) {
|
|
161
|
+
metadataContent = data;
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
await fileService.uploadFile(params);
|
|
166
|
+
|
|
167
|
+
expect(metadataContent).toBeTruthy();
|
|
168
|
+
const metadata = JSON.parse(metadataContent);
|
|
169
|
+
expect(metadata.filename).toBe('image.jpg');
|
|
170
|
+
expect(metadata.hash).toBe('xyz789');
|
|
171
|
+
expect(metadata.type).toBe('image/jpeg');
|
|
172
|
+
expect(metadata.size).toBe(100);
|
|
173
|
+
expect(metadata.createdAt).toBeDefined();
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('should handle upload failure and throw error', async () => {
|
|
177
|
+
const params = {
|
|
178
|
+
content: new ArrayBuffer(10),
|
|
179
|
+
filename: 'test.png',
|
|
180
|
+
hash: 'abc123',
|
|
181
|
+
path: 'uploads/test.png',
|
|
182
|
+
type: 'image/png',
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
mockWriteFile.mockRejectedValue(new Error('Disk full'));
|
|
186
|
+
|
|
187
|
+
await expect(fileService.uploadFile(params)).rejects.toThrow('File upload failed: Disk full');
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('should handle file path with no directory', async () => {
|
|
191
|
+
const params = {
|
|
192
|
+
content: new ArrayBuffer(10),
|
|
193
|
+
filename: 'test.txt',
|
|
194
|
+
hash: 'abc',
|
|
195
|
+
path: 'test.txt',
|
|
196
|
+
type: 'text/plain',
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
mockWriteFile.mockResolvedValue(undefined);
|
|
200
|
+
|
|
201
|
+
const result = await fileService.uploadFile(params);
|
|
202
|
+
|
|
203
|
+
expect(result.success).toBe(true);
|
|
204
|
+
expect(result.metadata.dirname).toBe('');
|
|
205
|
+
expect(result.metadata.filename).toBe('test.txt');
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
describe('getFile', () => {
|
|
210
|
+
it('should get file from new path format successfully', async () => {
|
|
211
|
+
const mockContent = Buffer.from('test content');
|
|
212
|
+
|
|
213
|
+
mockFsReadFile.mockImplementation((path: any, callback: any) => {
|
|
214
|
+
callback(null, mockContent);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// Mock metadata read failure, will infer from extension
|
|
218
|
+
mockReadFile.mockRejectedValue(new Error('No metadata'));
|
|
219
|
+
|
|
220
|
+
const result = await fileService.getFile('desktop://documents/test.txt');
|
|
221
|
+
|
|
222
|
+
// Since metadata fails, it will use default or infer from extension
|
|
223
|
+
expect(result.mimeType).toBeDefined();
|
|
224
|
+
expect(result.content).toBeDefined();
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('should get file from legacy path format (timestamp directory)', async () => {
|
|
228
|
+
const mockContent = Buffer.from('legacy content');
|
|
229
|
+
|
|
230
|
+
mockFsReadFile.mockImplementation((path: any, callback: any) => {
|
|
231
|
+
callback(null, mockContent);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
// Mock metadata read to succeed this time
|
|
235
|
+
mockReadFile.mockResolvedValue(JSON.stringify({ type: 'image/png' }));
|
|
236
|
+
|
|
237
|
+
const result = await fileService.getFile('desktop://1234567890/abc123.png');
|
|
238
|
+
|
|
239
|
+
// Check that result is returned
|
|
240
|
+
expect(result.mimeType).toBeDefined();
|
|
241
|
+
expect(result.content).toBeDefined();
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('should fallback from legacy to new path on failure', async () => {
|
|
245
|
+
const mockContent = Buffer.from('fallback content');
|
|
246
|
+
|
|
247
|
+
let callCount = 0;
|
|
248
|
+
mockFsReadFile.mockImplementation((path: any, callback: any) => {
|
|
249
|
+
callCount++;
|
|
250
|
+
if (callCount === 1) {
|
|
251
|
+
// First read (legacy) fails
|
|
252
|
+
const error: any = new Error('ENOENT');
|
|
253
|
+
error.code = 'ENOENT';
|
|
254
|
+
callback(error, null);
|
|
255
|
+
} else {
|
|
256
|
+
// Second read (fallback) succeeds
|
|
257
|
+
callback(null, mockContent);
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
mockReadFile.mockRejectedValue(new Error('No metadata'));
|
|
262
|
+
|
|
263
|
+
const result = await fileService.getFile('desktop://1234567890/fallback.jpg');
|
|
264
|
+
|
|
265
|
+
// Check that fallback worked and result is returned
|
|
266
|
+
expect(result.content).toBeDefined();
|
|
267
|
+
expect(result.mimeType).toBeDefined();
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it('should infer MIME type from file extension when metadata missing', async () => {
|
|
271
|
+
const mockContent = Buffer.from('image data');
|
|
272
|
+
|
|
273
|
+
mockFsReadFile.mockImplementation((path: any, callback: any) => {
|
|
274
|
+
callback(null, mockContent);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
mockReadFile.mockRejectedValue(new Error('Metadata not found'));
|
|
278
|
+
|
|
279
|
+
const result = await fileService.getFile('desktop://images/photo.png');
|
|
280
|
+
|
|
281
|
+
expect(result.mimeType).toBe('image/png');
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it('should infer correct MIME types for various image formats', async () => {
|
|
285
|
+
const mockContent = Buffer.from('image');
|
|
286
|
+
|
|
287
|
+
const testCases = [
|
|
288
|
+
{ path: 'desktop://test.jpg', expected: 'image/jpeg' },
|
|
289
|
+
{ path: 'desktop://test.jpeg', expected: 'image/jpeg' },
|
|
290
|
+
{ path: 'desktop://test.gif', expected: 'image/gif' },
|
|
291
|
+
{ path: 'desktop://test.webp', expected: 'image/webp' },
|
|
292
|
+
{ path: 'desktop://test.svg', expected: 'image/svg+xml' },
|
|
293
|
+
{ path: 'desktop://test.pdf', expected: 'application/pdf' },
|
|
294
|
+
];
|
|
295
|
+
|
|
296
|
+
mockFsReadFile.mockImplementation((path: any, callback: any) => {
|
|
297
|
+
callback(null, mockContent);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
for (const testCase of testCases) {
|
|
301
|
+
mockReadFile.mockRejectedValue(new Error('No metadata'));
|
|
302
|
+
|
|
303
|
+
const result = await fileService.getFile(testCase.path);
|
|
304
|
+
expect(result.mimeType).toBe(testCase.expected);
|
|
305
|
+
}
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it('should use default MIME type for unknown extensions', async () => {
|
|
309
|
+
const mockContent = Buffer.from('unknown');
|
|
310
|
+
|
|
311
|
+
mockFsReadFile.mockImplementation((path: any, callback: any) => {
|
|
312
|
+
callback(null, mockContent);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
mockReadFile.mockRejectedValue(new Error('No metadata'));
|
|
316
|
+
|
|
317
|
+
const result = await fileService.getFile('desktop://file.unknown');
|
|
318
|
+
|
|
319
|
+
expect(result.mimeType).toBe('application/octet-stream');
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it('should throw FileNotFoundError when file does not exist', async () => {
|
|
323
|
+
mockFsReadFile.mockImplementation((path: any, callback: any) => {
|
|
324
|
+
const error: any = new Error('ENOENT: no such file');
|
|
325
|
+
error.code = 'ENOENT';
|
|
326
|
+
error.message = 'ENOENT: no such file';
|
|
327
|
+
callback(error, null);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
await expect(fileService.getFile('desktop://missing/file.txt')).rejects.toThrow(
|
|
331
|
+
FileNotFoundError,
|
|
332
|
+
);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it('should throw error for invalid path without desktop:// prefix', async () => {
|
|
336
|
+
await expect(fileService.getFile('/invalid/path.txt')).rejects.toThrow(
|
|
337
|
+
'Invalid desktop file path',
|
|
338
|
+
);
|
|
339
|
+
});
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
describe('deleteFile', () => {
|
|
343
|
+
it('should delete file from new path format successfully', async () => {
|
|
344
|
+
mockFsUnlink.mockImplementation((path: any, callback: any) => {
|
|
345
|
+
callback(null);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
const result = await fileService.deleteFile('desktop://documents/test.txt');
|
|
349
|
+
|
|
350
|
+
expect(result.success).toBe(true);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it('should delete file from legacy path format', async () => {
|
|
354
|
+
mockFsUnlink.mockImplementation((path: any, callback: any) => {
|
|
355
|
+
callback(null);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
const result = await fileService.deleteFile('desktop://1234567890/file.png');
|
|
359
|
+
|
|
360
|
+
expect(result.success).toBe(true);
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it('should fallback from legacy to new path on deletion failure', async () => {
|
|
364
|
+
let callCount = 0;
|
|
365
|
+
mockFsUnlink.mockImplementation((path: any, callback: any) => {
|
|
366
|
+
callCount++;
|
|
367
|
+
if (callCount === 1) {
|
|
368
|
+
// First attempt (legacy file) fails
|
|
369
|
+
callback(new Error('ENOENT'));
|
|
370
|
+
} else {
|
|
371
|
+
// All subsequent attempts succeed
|
|
372
|
+
callback(null);
|
|
373
|
+
}
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
const result = await fileService.deleteFile('desktop://1234567890/fallback.txt');
|
|
377
|
+
|
|
378
|
+
expect(result.success).toBe(true);
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it('should handle metadata deletion failure gracefully', async () => {
|
|
382
|
+
let callCount = 0;
|
|
383
|
+
mockFsUnlink.mockImplementation((path: any, callback: any) => {
|
|
384
|
+
callCount++;
|
|
385
|
+
if (callCount === 1) {
|
|
386
|
+
// File deletion succeeds
|
|
387
|
+
callback(null);
|
|
388
|
+
} else {
|
|
389
|
+
// Metadata deletion fails (but doesn't throw)
|
|
390
|
+
callback(new Error('Metadata not found'));
|
|
391
|
+
}
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
const result = await fileService.deleteFile('desktop://files/test.txt');
|
|
395
|
+
|
|
396
|
+
expect(result.success).toBe(true);
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
it('should throw error when file deletion fails', async () => {
|
|
400
|
+
mockFsUnlink.mockImplementation((path: any, callback: any) => {
|
|
401
|
+
callback(new Error('Permission denied'));
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
await expect(fileService.deleteFile('desktop://protected/file.txt')).rejects.toThrow(
|
|
405
|
+
'File deletion failed: Permission denied',
|
|
406
|
+
);
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
it('should throw error for invalid path without desktop:// prefix', async () => {
|
|
410
|
+
await expect(fileService.deleteFile('/invalid/path.txt')).rejects.toThrow(
|
|
411
|
+
'Invalid desktop file path',
|
|
412
|
+
);
|
|
413
|
+
});
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
describe('deleteFiles', () => {
|
|
417
|
+
it('should delete multiple files successfully', async () => {
|
|
418
|
+
mockFsUnlink.mockImplementation((path: any, callback: any) => {
|
|
419
|
+
callback(null);
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
const paths = [
|
|
423
|
+
'desktop://files/file1.txt',
|
|
424
|
+
'desktop://files/file2.txt',
|
|
425
|
+
'desktop://files/file3.txt',
|
|
426
|
+
];
|
|
427
|
+
|
|
428
|
+
const result = await fileService.deleteFiles(paths);
|
|
429
|
+
|
|
430
|
+
expect(result.success).toBe(true);
|
|
431
|
+
expect(result.errors).toBeUndefined();
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
it('should handle partial failures in batch deletion', async () => {
|
|
435
|
+
let callCount = 0;
|
|
436
|
+
mockFsUnlink.mockImplementation((path: any, callback: any) => {
|
|
437
|
+
callCount++;
|
|
438
|
+
// Fail on a specific file
|
|
439
|
+
if (path.includes('file2.txt') && !path.includes('.meta')) {
|
|
440
|
+
callback(new Error('Permission denied'));
|
|
441
|
+
} else {
|
|
442
|
+
callback(null);
|
|
443
|
+
}
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
const paths = [
|
|
447
|
+
'desktop://files/file1.txt',
|
|
448
|
+
'desktop://files/file2.txt',
|
|
449
|
+
'desktop://files/file3.txt',
|
|
450
|
+
];
|
|
451
|
+
|
|
452
|
+
const result = await fileService.deleteFiles(paths);
|
|
453
|
+
|
|
454
|
+
expect(result.success).toBe(false);
|
|
455
|
+
expect(result.errors).toBeDefined();
|
|
456
|
+
expect(result.errors?.length).toBeGreaterThan(0);
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
it('should return errors array with failed file paths', async () => {
|
|
460
|
+
mockFsUnlink.mockImplementation((path: any, callback: any) => {
|
|
461
|
+
if (path.includes('file2') && !path.includes('.meta')) {
|
|
462
|
+
callback(new Error('Access denied'));
|
|
463
|
+
} else {
|
|
464
|
+
callback(null);
|
|
465
|
+
}
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
const paths = ['desktop://files/file1.txt', 'desktop://files/file2.txt'];
|
|
469
|
+
|
|
470
|
+
const result = await fileService.deleteFiles(paths);
|
|
471
|
+
|
|
472
|
+
expect(result.success).toBe(false);
|
|
473
|
+
expect(result.errors).toHaveLength(1);
|
|
474
|
+
expect(result.errors?.[0].path).toBe('desktop://files/file2.txt');
|
|
475
|
+
expect(result.errors?.[0].message).toContain('Access denied');
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
it('should handle empty paths array', async () => {
|
|
479
|
+
const result = await fileService.deleteFiles([]);
|
|
480
|
+
|
|
481
|
+
expect(result.success).toBe(true);
|
|
482
|
+
expect(result.errors).toBeUndefined();
|
|
483
|
+
});
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
describe('getFilePath', () => {
|
|
487
|
+
it('should return correct path for new format', async () => {
|
|
488
|
+
mockAccess.mockResolvedValue(undefined);
|
|
489
|
+
|
|
490
|
+
const result = await fileService.getFilePath('desktop://documents/test.txt');
|
|
491
|
+
|
|
492
|
+
expect(result).toBe('/mock/app/storage/file-storage/documents/test.txt');
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
it('should return legacy path when file exists in uploads directory', async () => {
|
|
496
|
+
mockAccess.mockResolvedValue(undefined);
|
|
497
|
+
|
|
498
|
+
const result = await fileService.getFilePath('desktop://1234567890/legacy.png');
|
|
499
|
+
|
|
500
|
+
expect(result).toBe('/mock/app/storage/file-storage/uploads/1234567890/legacy.png');
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
it('should fallback to new path when legacy path does not exist', async () => {
|
|
504
|
+
mockAccess
|
|
505
|
+
.mockRejectedValueOnce(new Error('Not found')) // legacy fails
|
|
506
|
+
.mockResolvedValueOnce(undefined); // fallback succeeds
|
|
507
|
+
|
|
508
|
+
const result = await fileService.getFilePath('desktop://1234567890/migrated.png');
|
|
509
|
+
|
|
510
|
+
// When legacy path doesn't exist and fallback exists, it returns the fallback path
|
|
511
|
+
// But since isLegacyPath returns true for timestamps, and the fallback succeeds,
|
|
512
|
+
// it should update to the fallback path
|
|
513
|
+
expect(result).toContain('1234567890/migrated.png');
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
it('should return legacy path when both paths do not exist', async () => {
|
|
517
|
+
mockAccess
|
|
518
|
+
.mockRejectedValueOnce(new Error('Not found'))
|
|
519
|
+
.mockRejectedValueOnce(new Error('Not found'));
|
|
520
|
+
|
|
521
|
+
const result = await fileService.getFilePath('desktop://1234567890/missing.png');
|
|
522
|
+
|
|
523
|
+
expect(result).toBe('/mock/app/storage/file-storage/uploads/1234567890/missing.png');
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
it('should throw error for invalid path', async () => {
|
|
527
|
+
await expect(fileService.getFilePath('/invalid/path')).rejects.toThrow(
|
|
528
|
+
'Invalid desktop file path',
|
|
529
|
+
);
|
|
530
|
+
});
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
describe('getFileHTTPURL', () => {
|
|
534
|
+
it('should generate correct HTTP URL for new format', async () => {
|
|
535
|
+
const result = await fileService.getFileHTTPURL('desktop://documents/photo.jpg');
|
|
536
|
+
|
|
537
|
+
expect(result).toBe('http://localhost:3000/lobe-desktop-file/documents/photo.jpg');
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
it('should generate correct HTTP URL for legacy format', async () => {
|
|
541
|
+
const result = await fileService.getFileHTTPURL('desktop://1234567890/image.png');
|
|
542
|
+
|
|
543
|
+
expect(result).toBe('http://localhost:3000/lobe-desktop-file/1234567890/image.png');
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
it('should throw error for invalid path', async () => {
|
|
547
|
+
await expect(fileService.getFileHTTPURL('/invalid/path')).rejects.toThrow(
|
|
548
|
+
'Invalid desktop file path',
|
|
549
|
+
);
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
it('should handle paths with special characters', async () => {
|
|
553
|
+
const result = await fileService.getFileHTTPURL('desktop://user/my%20file.txt');
|
|
554
|
+
|
|
555
|
+
expect(result).toBe('http://localhost:3000/lobe-desktop-file/user/my%20file.txt');
|
|
556
|
+
});
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
describe('isLegacyPath (via behavior testing)', () => {
|
|
560
|
+
it('should treat timestamp-based paths as legacy', async () => {
|
|
561
|
+
mockAccess.mockResolvedValue(undefined);
|
|
562
|
+
|
|
563
|
+
const result = await fileService.getFilePath('desktop://1234567890/file.txt');
|
|
564
|
+
|
|
565
|
+
// Legacy paths go to uploads directory
|
|
566
|
+
expect(result).toContain('uploads/1234567890/file.txt');
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
it('should treat custom paths as new format', async () => {
|
|
570
|
+
mockAccess.mockResolvedValue(undefined);
|
|
571
|
+
|
|
572
|
+
const result = await fileService.getFilePath('desktop://custom/path/file.txt');
|
|
573
|
+
|
|
574
|
+
expect(result).toContain('file-storage/custom/path/file.txt');
|
|
575
|
+
expect(result).not.toContain('uploads');
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
it('should handle single-level paths correctly', async () => {
|
|
579
|
+
mockAccess.mockResolvedValue(undefined);
|
|
580
|
+
|
|
581
|
+
const result = await fileService.getFilePath('desktop://file.txt');
|
|
582
|
+
|
|
583
|
+
expect(result).toContain('file-storage/file.txt');
|
|
584
|
+
});
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
describe('UPLOADS_DIR getter', () => {
|
|
588
|
+
it('should return correct uploads directory path', () => {
|
|
589
|
+
expect(fileService.UPLOADS_DIR).toBe('/mock/app/storage/file-storage/uploads');
|
|
590
|
+
});
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
describe('FileNotFoundError', () => {
|
|
594
|
+
it('should create error with correct properties', () => {
|
|
595
|
+
const error = new FileNotFoundError('File not found', 'desktop://missing.txt');
|
|
596
|
+
|
|
597
|
+
expect(error.name).toBe('FileNotFoundError');
|
|
598
|
+
expect(error.message).toBe('File not found');
|
|
599
|
+
expect(error.path).toBe('desktop://missing.txt');
|
|
600
|
+
expect(error instanceof Error).toBe(true);
|
|
601
|
+
});
|
|
602
|
+
});
|
|
603
|
+
});
|
package/changelog/v1.json
CHANGED