@lobehub/lobehub 2.0.0-next.31 → 2.0.0-next.33
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/.github/workflows/test.yml +1 -0
- package/CHANGELOG.md +58 -0
- package/apps/desktop/package.json +1 -1
- package/apps/desktop/src/main/core/infrastructure/UpdaterManager.ts +23 -2
- package/changelog/v1.json +21 -0
- package/docker-compose/local/.env.example +3 -0
- package/docs/self-hosting/server-database/docker-compose.mdx +29 -0
- package/docs/self-hosting/server-database/docker-compose.zh-CN.mdx +29 -0
- package/package.json +1 -1
- package/packages/const/src/hotkeys.ts +3 -3
- package/packages/const/src/models.ts +2 -2
- package/packages/const/src/utils/merge.ts +3 -3
- package/packages/conversation-flow/package.json +13 -0
- package/packages/conversation-flow/src/__tests__/fixtures/index.ts +48 -0
- package/packages/conversation-flow/src/__tests__/fixtures/inputs/assistant-chain-with-followup.json +56 -0
- package/packages/conversation-flow/src/__tests__/fixtures/inputs/assistant-with-tools.json +144 -0
- package/packages/conversation-flow/src/__tests__/fixtures/inputs/branch/active-index-1.json +131 -0
- package/packages/conversation-flow/src/__tests__/fixtures/inputs/branch/assistant-branch.json +96 -0
- package/packages/conversation-flow/src/__tests__/fixtures/inputs/branch/assistant-user-branch.json +123 -0
- package/packages/conversation-flow/src/__tests__/fixtures/inputs/branch/conversation.json +128 -0
- package/packages/conversation-flow/src/__tests__/fixtures/inputs/branch/index.ts +14 -0
- package/packages/conversation-flow/src/__tests__/fixtures/inputs/branch/nested.json +179 -0
- package/packages/conversation-flow/src/__tests__/fixtures/inputs/compare/index.ts +8 -0
- package/packages/conversation-flow/src/__tests__/fixtures/inputs/compare/simple.json +85 -0
- package/packages/conversation-flow/src/__tests__/fixtures/inputs/compare/with-tools.json +169 -0
- package/packages/conversation-flow/src/__tests__/fixtures/inputs/complex-scenario.json +107 -0
- package/packages/conversation-flow/src/__tests__/fixtures/inputs/index.ts +14 -0
- package/packages/conversation-flow/src/__tests__/fixtures/inputs/linear-conversation.json +59 -0
- package/packages/conversation-flow/src/__tests__/fixtures/outputs/assistant-chain-with-followup.json +135 -0
- package/packages/conversation-flow/src/__tests__/fixtures/outputs/assistant-with-tools.json +340 -0
- package/packages/conversation-flow/src/__tests__/fixtures/outputs/branch/active-index-1.json +242 -0
- package/packages/conversation-flow/src/__tests__/fixtures/outputs/branch/assistant-branch.json +208 -0
- package/packages/conversation-flow/src/__tests__/fixtures/outputs/branch/assistant-user-branch.json +254 -0
- package/packages/conversation-flow/src/__tests__/fixtures/outputs/branch/conversation.json +260 -0
- package/packages/conversation-flow/src/__tests__/fixtures/outputs/branch/index.ts +14 -0
- package/packages/conversation-flow/src/__tests__/fixtures/outputs/branch/nested.json +389 -0
- package/packages/conversation-flow/src/__tests__/fixtures/outputs/compare/index.ts +8 -0
- package/packages/conversation-flow/src/__tests__/fixtures/outputs/compare/simple.json +224 -0
- package/packages/conversation-flow/src/__tests__/fixtures/outputs/compare/with-tools.json +418 -0
- package/packages/conversation-flow/src/__tests__/fixtures/outputs/complex-scenario.json +239 -0
- package/packages/conversation-flow/src/__tests__/fixtures/outputs/linear-conversation.json +138 -0
- package/packages/conversation-flow/src/__tests__/parse.test.ts +97 -0
- package/packages/conversation-flow/src/index.ts +17 -0
- package/packages/conversation-flow/src/indexing.ts +58 -0
- package/packages/conversation-flow/src/parse.ts +53 -0
- package/packages/conversation-flow/src/structuring.ts +38 -0
- package/packages/conversation-flow/src/transformation/BranchResolver.ts +66 -0
- package/packages/conversation-flow/src/transformation/ContextTreeBuilder.ts +292 -0
- package/packages/conversation-flow/src/transformation/FlatListBuilder.ts +421 -0
- package/packages/conversation-flow/src/transformation/MessageCollector.ts +166 -0
- package/packages/conversation-flow/src/transformation/MessageTransformer.ts +177 -0
- package/packages/conversation-flow/src/transformation/__tests__/BranchResolver.test.ts +151 -0
- package/packages/conversation-flow/src/transformation/__tests__/ContextTreeBuilder.test.ts +385 -0
- package/packages/conversation-flow/src/transformation/__tests__/FlatListBuilder.test.ts +511 -0
- package/packages/conversation-flow/src/transformation/__tests__/MessageCollector.test.ts +220 -0
- package/packages/conversation-flow/src/transformation/__tests__/MessageTransformer.test.ts +287 -0
- package/packages/conversation-flow/src/transformation/index.ts +78 -0
- package/packages/conversation-flow/src/types/contextTree.ts +65 -0
- package/packages/conversation-flow/src/types/flatMessageList.ts +66 -0
- package/packages/conversation-flow/src/types/shared.ts +63 -0
- package/packages/conversation-flow/src/types.ts +36 -0
- package/packages/conversation-flow/vitest.config.mts +10 -0
- package/packages/types/src/message/common/metadata.ts +5 -1
- package/src/app/[variants]/(main)/chat/@session/features/SessionListContent/List/Item/index.tsx +3 -4
- package/src/envs/__tests__/app.test.ts +47 -13
- package/src/envs/app.ts +6 -0
- package/src/server/modules/S3/index.test.ts +379 -0
- package/src/server/routers/async/__tests__/caller.test.ts +333 -0
- package/src/server/routers/async/caller.ts +2 -1
- package/src/server/routers/lambda/__tests__/integration/message.integration.test.ts +57 -57
- package/src/server/routers/lambda/message.ts +2 -2
- package/src/server/services/message/__tests__/index.test.ts +4 -4
- package/src/server/services/message/index.ts +1 -1
- package/src/services/message/index.ts +2 -3
- package/src/store/chat/slices/aiChat/actions/__tests__/generateAIChat.test.ts +8 -8
- package/src/store/chat/slices/aiChat/actions/__tests__/generateAIChatV2.test.ts +8 -8
- package/src/store/chat/slices/aiChat/actions/__tests__/helpers.ts +1 -1
- package/src/store/chat/slices/aiChat/actions/generateAIChatV2.ts +1 -1
- package/src/store/chat/slices/message/action.test.ts +7 -7
- package/src/store/chat/slices/message/action.ts +2 -2
- package/src/store/chat/slices/plugin/action.test.ts +7 -7
- package/src/store/chat/slices/plugin/action.ts +1 -1
- package/packages/context-engine/ARCHITECTURE.md +0 -425
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { UIChatMessage } from '@lobechat/types';
|
|
2
|
+
|
|
3
|
+
import type { ContextNode } from './contextTree';
|
|
4
|
+
import type { FlatMessage } from './flatMessageList';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Shared Types
|
|
8
|
+
*
|
|
9
|
+
* Common types used across the conversation flow engine.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Re-export UIChatMessage as Message for convenience
|
|
14
|
+
*/
|
|
15
|
+
export type Message = UIChatMessage;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Message group metadata from database
|
|
19
|
+
* Used for multi-model parallel conversations and manual grouping
|
|
20
|
+
*/
|
|
21
|
+
export interface MessageGroupMetadata {
|
|
22
|
+
description?: string;
|
|
23
|
+
id: string;
|
|
24
|
+
/** Presentation mode: compare, summary, or manual */
|
|
25
|
+
mode?: 'compare' | 'summary' | 'manual';
|
|
26
|
+
/** Parent message that triggered this group */
|
|
27
|
+
parentMessageId?: string;
|
|
28
|
+
title?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Internal structure node used during tree building
|
|
33
|
+
*/
|
|
34
|
+
export interface IdNode {
|
|
35
|
+
children: IdNode[];
|
|
36
|
+
id: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Result of the parse function
|
|
41
|
+
*/
|
|
42
|
+
export interface ParseResult {
|
|
43
|
+
/** Semantic tree structure for navigation and context understanding */
|
|
44
|
+
contextTree: ContextNode[];
|
|
45
|
+
/** Flattened list optimized for virtual list rendering */
|
|
46
|
+
flatList: FlatMessage[];
|
|
47
|
+
/** Map for O(1) message access */
|
|
48
|
+
messageMap: Record<string, Message>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Internal helper maps used during parsing
|
|
53
|
+
*/
|
|
54
|
+
export interface HelperMaps {
|
|
55
|
+
/** Maps parent ID to array of child IDs */
|
|
56
|
+
childrenMap: Map<string | null, string[]>;
|
|
57
|
+
/** Maps message group ID to its metadata */
|
|
58
|
+
messageGroupMap: Map<string, MessageGroupMetadata>;
|
|
59
|
+
/** Maps message ID to message */
|
|
60
|
+
messageMap: Map<string, Message>;
|
|
61
|
+
/** Maps thread ID to all messages in that thread */
|
|
62
|
+
threadMap: Map<string, Message[]>;
|
|
63
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type Index
|
|
3
|
+
*
|
|
4
|
+
* Centralized exports for all conversation flow types.
|
|
5
|
+
* Types are organized into three categories:
|
|
6
|
+
*
|
|
7
|
+
* 1. Context Tree (types/contextTree.ts) - Tree structure for navigation
|
|
8
|
+
* 2. Flat Message List (types/flatMessageList.ts) - Optimized for rendering
|
|
9
|
+
* 3. Shared (types/shared.ts) - Common types used across modules
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
// Context Tree Types
|
|
13
|
+
export type {
|
|
14
|
+
AssistantGroupNode,
|
|
15
|
+
BranchNode,
|
|
16
|
+
CompareNode,
|
|
17
|
+
ContextNode,
|
|
18
|
+
MessageNode,
|
|
19
|
+
} from './types/contextTree';
|
|
20
|
+
|
|
21
|
+
// Flat Message List Types
|
|
22
|
+
export type {
|
|
23
|
+
BranchMetadata,
|
|
24
|
+
FlatMessage,
|
|
25
|
+
FlatMessageExtra,
|
|
26
|
+
FlatMessageRole,
|
|
27
|
+
} from './types/flatMessageList';
|
|
28
|
+
|
|
29
|
+
// Shared Types
|
|
30
|
+
export type {
|
|
31
|
+
HelperMaps,
|
|
32
|
+
IdNode,
|
|
33
|
+
Message,
|
|
34
|
+
MessageGroupMetadata,
|
|
35
|
+
ParseResult,
|
|
36
|
+
} from './types/shared';
|
|
@@ -103,4 +103,8 @@ export interface ModelPerformance {
|
|
|
103
103
|
latency?: number;
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
-
export interface MessageMetadata extends ModelUsage, ModelPerformance {
|
|
106
|
+
export interface MessageMetadata extends ModelUsage, ModelPerformance {
|
|
107
|
+
activeBranchIndex?: number;
|
|
108
|
+
activeColumn?: boolean;
|
|
109
|
+
compare?: boolean;
|
|
110
|
+
}
|
package/src/app/[variants]/(main)/chat/@session/features/SessionListContent/List/Item/index.tsx
CHANGED
|
@@ -4,9 +4,8 @@ import { Flexbox } from 'react-layout-kit';
|
|
|
4
4
|
import { shallow } from 'zustand/shallow';
|
|
5
5
|
|
|
6
6
|
import { DEFAULT_AVATAR } from '@/const/meta';
|
|
7
|
+
import { INBOX_SESSION_ID } from '@/const/session';
|
|
7
8
|
import { isDesktop } from '@/const/version';
|
|
8
|
-
import { useAgentStore } from '@/store/agent';
|
|
9
|
-
import { agentSelectors } from '@/store/agent/selectors';
|
|
10
9
|
import { useChatStore } from '@/store/chat';
|
|
11
10
|
import { messageStateSelectors } from '@/store/chat/selectors';
|
|
12
11
|
import { useGlobalStore } from '@/store/global';
|
|
@@ -28,7 +27,6 @@ interface SessionItemProps {
|
|
|
28
27
|
const SessionItem = memo<SessionItemProps>(({ id }) => {
|
|
29
28
|
const [open, setOpen] = useState(false);
|
|
30
29
|
const [createGroupModalOpen, setCreateGroupModalOpen] = useState(false);
|
|
31
|
-
const [defaultModel] = useAgentStore((s) => [agentSelectors.inboxAgentModel(s)]);
|
|
32
30
|
|
|
33
31
|
const openSessionInNewWindow = useGlobalStore((s) => s.openSessionInNewWindow);
|
|
34
32
|
|
|
@@ -55,7 +53,8 @@ const SessionItem = memo<SessionItemProps>(({ id }) => {
|
|
|
55
53
|
];
|
|
56
54
|
});
|
|
57
55
|
|
|
58
|
-
|
|
56
|
+
// Only hide the model tag for the inbox session itself (随便聊聊)
|
|
57
|
+
const showModel = sessionType === 'agent' && model && id !== INBOX_SESSION_ID;
|
|
59
58
|
|
|
60
59
|
const handleDoubleClick = () => {
|
|
61
60
|
if (isDesktop) {
|
|
@@ -1,18 +1,10 @@
|
|
|
1
1
|
// @vitest-environment node
|
|
2
2
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
3
|
|
|
4
|
-
import { getAppConfig } from '../app';
|
|
5
|
-
|
|
6
|
-
// Stub the global process object to safely mock environment variables
|
|
7
|
-
vi.stubGlobal('process', {
|
|
8
|
-
...process, // Preserve the original process object
|
|
9
|
-
env: { ...process.env }, // Clone the environment variables object for modification
|
|
10
|
-
});
|
|
11
|
-
|
|
12
4
|
describe('getServerConfig', () => {
|
|
13
5
|
beforeEach(() => {
|
|
14
|
-
// Reset
|
|
15
|
-
vi.
|
|
6
|
+
// Reset modules to clear the cached config
|
|
7
|
+
vi.resetModules();
|
|
16
8
|
});
|
|
17
9
|
|
|
18
10
|
// it('correctly handles values for OPENAI_FUNCTION_REGIONS', () => {
|
|
@@ -22,7 +14,8 @@ describe('getServerConfig', () => {
|
|
|
22
14
|
// });
|
|
23
15
|
|
|
24
16
|
describe('index url', () => {
|
|
25
|
-
it('should return default URLs when no environment variables are set', () => {
|
|
17
|
+
it('should return default URLs when no environment variables are set', async () => {
|
|
18
|
+
const { getAppConfig } = await import('../app');
|
|
26
19
|
const config = getAppConfig();
|
|
27
20
|
expect(config.AGENTS_INDEX_URL).toBe(
|
|
28
21
|
'https://registry.npmmirror.com/@lobehub/agents-index/v1/files/public',
|
|
@@ -32,18 +25,20 @@ describe('getServerConfig', () => {
|
|
|
32
25
|
);
|
|
33
26
|
});
|
|
34
27
|
|
|
35
|
-
it('should return custom URLs when environment variables are set', () => {
|
|
28
|
+
it('should return custom URLs when environment variables are set', async () => {
|
|
36
29
|
process.env.AGENTS_INDEX_URL = 'https://custom-agents-url.com';
|
|
37
30
|
process.env.PLUGINS_INDEX_URL = 'https://custom-plugins-url.com';
|
|
31
|
+
const { getAppConfig } = await import('../app');
|
|
38
32
|
const config = getAppConfig();
|
|
39
33
|
expect(config.AGENTS_INDEX_URL).toBe('https://custom-agents-url.com');
|
|
40
34
|
expect(config.PLUGINS_INDEX_URL).toBe('https://custom-plugins-url.com');
|
|
41
35
|
});
|
|
42
36
|
|
|
43
|
-
it('should return default URLs when environment variables are empty string', () => {
|
|
37
|
+
it('should return default URLs when environment variables are empty string', async () => {
|
|
44
38
|
process.env.AGENTS_INDEX_URL = '';
|
|
45
39
|
process.env.PLUGINS_INDEX_URL = '';
|
|
46
40
|
|
|
41
|
+
const { getAppConfig } = await import('../app');
|
|
47
42
|
const config = getAppConfig();
|
|
48
43
|
expect(config.AGENTS_INDEX_URL).toBe(
|
|
49
44
|
'https://registry.npmmirror.com/@lobehub/agents-index/v1/files/public',
|
|
@@ -53,4 +48,43 @@ describe('getServerConfig', () => {
|
|
|
53
48
|
);
|
|
54
49
|
});
|
|
55
50
|
});
|
|
51
|
+
|
|
52
|
+
describe('INTERNAL_APP_URL', () => {
|
|
53
|
+
it('should default to APP_URL when INTERNAL_APP_URL is not set', async () => {
|
|
54
|
+
process.env.APP_URL = 'https://example.com';
|
|
55
|
+
delete process.env.INTERNAL_APP_URL;
|
|
56
|
+
|
|
57
|
+
const { getAppConfig } = await import('../app');
|
|
58
|
+
const config = getAppConfig();
|
|
59
|
+
expect(config.INTERNAL_APP_URL).toBe('https://example.com');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('should use INTERNAL_APP_URL when explicitly set', async () => {
|
|
63
|
+
process.env.APP_URL = 'https://public.example.com';
|
|
64
|
+
process.env.INTERNAL_APP_URL = 'http://localhost:3210';
|
|
65
|
+
|
|
66
|
+
const { getAppConfig } = await import('../app');
|
|
67
|
+
const config = getAppConfig();
|
|
68
|
+
expect(config.INTERNAL_APP_URL).toBe('http://localhost:3210');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('should use INTERNAL_APP_URL over APP_URL when both are set', async () => {
|
|
72
|
+
process.env.APP_URL = 'https://public.example.com';
|
|
73
|
+
process.env.INTERNAL_APP_URL = 'http://internal-service:3210';
|
|
74
|
+
|
|
75
|
+
const { getAppConfig } = await import('../app');
|
|
76
|
+
const config = getAppConfig();
|
|
77
|
+
expect(config.APP_URL).toBe('https://public.example.com');
|
|
78
|
+
expect(config.INTERNAL_APP_URL).toBe('http://internal-service:3210');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('should handle localhost INTERNAL_APP_URL for bypassing CDN', async () => {
|
|
82
|
+
process.env.APP_URL = 'https://cloudflare-proxied.com';
|
|
83
|
+
process.env.INTERNAL_APP_URL = 'http://127.0.0.1:3210';
|
|
84
|
+
|
|
85
|
+
const { getAppConfig } = await import('../app');
|
|
86
|
+
const config = getAppConfig();
|
|
87
|
+
expect(config.INTERNAL_APP_URL).toBe('http://127.0.0.1:3210');
|
|
88
|
+
});
|
|
89
|
+
});
|
|
56
90
|
});
|
package/src/envs/app.ts
CHANGED
|
@@ -20,6 +20,10 @@ const APP_URL = process.env.APP_URL
|
|
|
20
20
|
? vercelUrl
|
|
21
21
|
: 'http://localhost:3010';
|
|
22
22
|
|
|
23
|
+
// INTERNAL_APP_URL is used for server-to-server calls to bypass CDN/proxy
|
|
24
|
+
// Falls back to APP_URL if not set
|
|
25
|
+
const INTERNAL_APP_URL = process.env.INTERNAL_APP_URL || APP_URL;
|
|
26
|
+
|
|
23
27
|
const ASSISTANT_INDEX_URL = 'https://registry.npmmirror.com/@lobehub/agents-index/v1/files/public';
|
|
24
28
|
|
|
25
29
|
const PLUGINS_INDEX_URL = 'https://registry.npmmirror.com/@lobehub/plugins-index/v1/files/public';
|
|
@@ -43,6 +47,7 @@ export const getAppConfig = () => {
|
|
|
43
47
|
PLUGIN_SETTINGS: z.string().optional(),
|
|
44
48
|
|
|
45
49
|
APP_URL: z.string().optional(),
|
|
50
|
+
INTERNAL_APP_URL: z.string().optional(),
|
|
46
51
|
VERCEL_EDGE_CONFIG: z.string().optional(),
|
|
47
52
|
MIDDLEWARE_REWRITE_THROUGH_LOCAL: z.boolean().optional(),
|
|
48
53
|
ENABLE_AUTH_PROTECTION: z.boolean().optional(),
|
|
@@ -77,6 +82,7 @@ export const getAppConfig = () => {
|
|
|
77
82
|
VERCEL_EDGE_CONFIG: process.env.VERCEL_EDGE_CONFIG,
|
|
78
83
|
|
|
79
84
|
APP_URL,
|
|
85
|
+
INTERNAL_APP_URL,
|
|
80
86
|
MIDDLEWARE_REWRITE_THROUGH_LOCAL: process.env.MIDDLEWARE_REWRITE_THROUGH_LOCAL === '1',
|
|
81
87
|
ENABLE_AUTH_PROTECTION: process.env.ENABLE_AUTH_PROTECTION === '1',
|
|
82
88
|
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
// @vitest-environment node
|
|
2
|
+
import {
|
|
3
|
+
DeleteObjectCommand,
|
|
4
|
+
DeleteObjectsCommand,
|
|
5
|
+
GetObjectCommand,
|
|
6
|
+
PutObjectCommand,
|
|
7
|
+
S3Client,
|
|
8
|
+
} from '@aws-sdk/client-s3';
|
|
9
|
+
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
|
10
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
11
|
+
|
|
12
|
+
import { S3 } from './index';
|
|
13
|
+
|
|
14
|
+
// Mock AWS SDK
|
|
15
|
+
vi.mock('@aws-sdk/client-s3');
|
|
16
|
+
vi.mock('@aws-sdk/s3-request-presigner');
|
|
17
|
+
|
|
18
|
+
// Mock environment variables
|
|
19
|
+
vi.mock('@/envs/file', () => ({
|
|
20
|
+
fileEnv: {
|
|
21
|
+
S3_ACCESS_KEY_ID: 'test-access-key',
|
|
22
|
+
S3_BUCKET: 'test-bucket',
|
|
23
|
+
S3_ENABLE_PATH_STYLE: false,
|
|
24
|
+
S3_ENDPOINT: 'https://s3.amazonaws.com',
|
|
25
|
+
S3_PREVIEW_URL_EXPIRE_IN: 7200,
|
|
26
|
+
S3_REGION: 'us-east-1',
|
|
27
|
+
S3_SECRET_ACCESS_KEY: 'test-secret-key',
|
|
28
|
+
S3_SET_ACL: true,
|
|
29
|
+
},
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
// Mock utilities
|
|
33
|
+
vi.mock('@/utils/url', () => ({
|
|
34
|
+
inferContentTypeFromImageUrl: vi.fn((key: string) => {
|
|
35
|
+
if (key.endsWith('.jpg') || key.endsWith('.jpeg')) return 'image/jpeg';
|
|
36
|
+
if (key.endsWith('.png')) return 'image/png';
|
|
37
|
+
if (key.endsWith('.gif')) return 'image/gif';
|
|
38
|
+
return 'application/octet-stream';
|
|
39
|
+
}),
|
|
40
|
+
}));
|
|
41
|
+
|
|
42
|
+
describe('S3', () => {
|
|
43
|
+
let mockS3ClientSend: ReturnType<typeof vi.fn>;
|
|
44
|
+
let mockGetSignedUrl: ReturnType<typeof vi.fn>;
|
|
45
|
+
|
|
46
|
+
beforeEach(() => {
|
|
47
|
+
vi.clearAllMocks();
|
|
48
|
+
|
|
49
|
+
// Setup S3Client mock
|
|
50
|
+
mockS3ClientSend = vi.fn();
|
|
51
|
+
(S3Client as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
|
|
52
|
+
send: mockS3ClientSend,
|
|
53
|
+
}));
|
|
54
|
+
|
|
55
|
+
// Setup getSignedUrl mock
|
|
56
|
+
mockGetSignedUrl = vi.fn().mockResolvedValue('https://presigned-url.example.com');
|
|
57
|
+
(getSignedUrl as unknown as ReturnType<typeof vi.fn>).mockImplementation(mockGetSignedUrl);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe('constructor', () => {
|
|
61
|
+
it('should initialize S3 client with correct configuration', () => {
|
|
62
|
+
new S3();
|
|
63
|
+
|
|
64
|
+
expect(S3Client).toHaveBeenCalledWith({
|
|
65
|
+
credentials: {
|
|
66
|
+
accessKeyId: 'test-access-key',
|
|
67
|
+
secretAccessKey: 'test-secret-key',
|
|
68
|
+
},
|
|
69
|
+
endpoint: 'https://s3.amazonaws.com',
|
|
70
|
+
forcePathStyle: false,
|
|
71
|
+
region: 'us-east-1',
|
|
72
|
+
requestChecksumCalculation: 'WHEN_REQUIRED',
|
|
73
|
+
responseChecksumValidation: 'WHEN_REQUIRED',
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('should use default region when S3_REGION is not set', () => {
|
|
78
|
+
vi.doMock('@/envs/file', () => ({
|
|
79
|
+
fileEnv: {
|
|
80
|
+
S3_ACCESS_KEY_ID: 'test-access-key',
|
|
81
|
+
S3_BUCKET: 'test-bucket',
|
|
82
|
+
S3_ENABLE_PATH_STYLE: false,
|
|
83
|
+
S3_ENDPOINT: 'https://s3.amazonaws.com',
|
|
84
|
+
S3_PREVIEW_URL_EXPIRE_IN: 7200,
|
|
85
|
+
S3_REGION: '',
|
|
86
|
+
S3_SECRET_ACCESS_KEY: 'test-secret-key',
|
|
87
|
+
S3_SET_ACL: true,
|
|
88
|
+
},
|
|
89
|
+
}));
|
|
90
|
+
|
|
91
|
+
new S3();
|
|
92
|
+
|
|
93
|
+
expect(S3Client).toHaveBeenCalledWith(
|
|
94
|
+
expect.objectContaining({
|
|
95
|
+
region: 'us-east-1',
|
|
96
|
+
}),
|
|
97
|
+
);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe('deleteFile', () => {
|
|
102
|
+
it('should delete a file with the correct parameters', async () => {
|
|
103
|
+
const s3 = new S3();
|
|
104
|
+
mockS3ClientSend.mockResolvedValue({});
|
|
105
|
+
|
|
106
|
+
await s3.deleteFile('test-key.txt');
|
|
107
|
+
|
|
108
|
+
expect(DeleteObjectCommand).toHaveBeenCalledWith({
|
|
109
|
+
Bucket: 'test-bucket',
|
|
110
|
+
Key: 'test-key.txt',
|
|
111
|
+
});
|
|
112
|
+
expect(mockS3ClientSend).toHaveBeenCalled();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('should handle deletion errors', async () => {
|
|
116
|
+
const s3 = new S3();
|
|
117
|
+
const error = new Error('Delete failed');
|
|
118
|
+
mockS3ClientSend.mockRejectedValue(error);
|
|
119
|
+
|
|
120
|
+
await expect(s3.deleteFile('test-key.txt')).rejects.toThrow('Delete failed');
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe('deleteFiles', () => {
|
|
125
|
+
it('should delete multiple files with correct parameters', async () => {
|
|
126
|
+
const s3 = new S3();
|
|
127
|
+
mockS3ClientSend.mockResolvedValue({});
|
|
128
|
+
|
|
129
|
+
const keys = ['file1.txt', 'file2.txt', 'file3.txt'];
|
|
130
|
+
await s3.deleteFiles(keys);
|
|
131
|
+
|
|
132
|
+
expect(DeleteObjectsCommand).toHaveBeenCalledWith({
|
|
133
|
+
Bucket: 'test-bucket',
|
|
134
|
+
Delete: {
|
|
135
|
+
Objects: [{ Key: 'file1.txt' }, { Key: 'file2.txt' }, { Key: 'file3.txt' }],
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
expect(mockS3ClientSend).toHaveBeenCalled();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('should handle empty array', async () => {
|
|
142
|
+
const s3 = new S3();
|
|
143
|
+
mockS3ClientSend.mockResolvedValue({});
|
|
144
|
+
|
|
145
|
+
await s3.deleteFiles([]);
|
|
146
|
+
|
|
147
|
+
expect(DeleteObjectsCommand).toHaveBeenCalledWith({
|
|
148
|
+
Bucket: 'test-bucket',
|
|
149
|
+
Delete: {
|
|
150
|
+
Objects: [],
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
describe('getFileContent', () => {
|
|
157
|
+
it('should retrieve file content as string', async () => {
|
|
158
|
+
const s3 = new S3();
|
|
159
|
+
const mockContent = 'Hello, World!';
|
|
160
|
+
mockS3ClientSend.mockResolvedValue({
|
|
161
|
+
Body: {
|
|
162
|
+
transformToString: vi.fn().mockResolvedValue(mockContent),
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const result = await s3.getFileContent('test-file.txt');
|
|
167
|
+
|
|
168
|
+
expect(GetObjectCommand).toHaveBeenCalledWith({
|
|
169
|
+
Bucket: 'test-bucket',
|
|
170
|
+
Key: 'test-file.txt',
|
|
171
|
+
});
|
|
172
|
+
expect(result).toBe(mockContent);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('should throw error when response body is missing', async () => {
|
|
176
|
+
const s3 = new S3();
|
|
177
|
+
mockS3ClientSend.mockResolvedValue({
|
|
178
|
+
Body: undefined,
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
await expect(s3.getFileContent('test-file.txt')).rejects.toThrow(
|
|
182
|
+
'No body in response with test-file.txt',
|
|
183
|
+
);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
describe('getFileByteArray', () => {
|
|
188
|
+
it('should retrieve file content as byte array', async () => {
|
|
189
|
+
const s3 = new S3();
|
|
190
|
+
const mockBytes = new Uint8Array([1, 2, 3, 4, 5]);
|
|
191
|
+
mockS3ClientSend.mockResolvedValue({
|
|
192
|
+
Body: {
|
|
193
|
+
transformToByteArray: vi.fn().mockResolvedValue(mockBytes),
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
const result = await s3.getFileByteArray('test-file.bin');
|
|
198
|
+
|
|
199
|
+
expect(GetObjectCommand).toHaveBeenCalledWith({
|
|
200
|
+
Bucket: 'test-bucket',
|
|
201
|
+
Key: 'test-file.bin',
|
|
202
|
+
});
|
|
203
|
+
expect(result).toEqual(mockBytes);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('should throw error when response body is missing', async () => {
|
|
207
|
+
const s3 = new S3();
|
|
208
|
+
mockS3ClientSend.mockResolvedValue({
|
|
209
|
+
Body: undefined,
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
await expect(s3.getFileByteArray('test-file.bin')).rejects.toThrow(
|
|
213
|
+
'No body in response with test-file.bin',
|
|
214
|
+
);
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
describe('createPreSignedUrl', () => {
|
|
219
|
+
it('should create presigned URL for upload with ACL', async () => {
|
|
220
|
+
const s3 = new S3();
|
|
221
|
+
|
|
222
|
+
const result = await s3.createPreSignedUrl('upload-file.txt');
|
|
223
|
+
|
|
224
|
+
expect(PutObjectCommand).toHaveBeenCalledWith({
|
|
225
|
+
ACL: 'public-read',
|
|
226
|
+
Bucket: 'test-bucket',
|
|
227
|
+
Key: 'upload-file.txt',
|
|
228
|
+
});
|
|
229
|
+
expect(mockGetSignedUrl).toHaveBeenCalledWith(expect.anything(), expect.anything(), {
|
|
230
|
+
expiresIn: 3600,
|
|
231
|
+
});
|
|
232
|
+
expect(result).toBe('https://presigned-url.example.com');
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
describe('createPreSignedUrlForPreview', () => {
|
|
237
|
+
it('should create presigned URL for preview with default expiration', async () => {
|
|
238
|
+
const s3 = new S3();
|
|
239
|
+
|
|
240
|
+
const result = await s3.createPreSignedUrlForPreview('preview-file.jpg');
|
|
241
|
+
|
|
242
|
+
expect(GetObjectCommand).toHaveBeenCalledWith({
|
|
243
|
+
Bucket: 'test-bucket',
|
|
244
|
+
Key: 'preview-file.jpg',
|
|
245
|
+
});
|
|
246
|
+
expect(mockGetSignedUrl).toHaveBeenCalledWith(expect.anything(), expect.anything(), {
|
|
247
|
+
expiresIn: 7200,
|
|
248
|
+
});
|
|
249
|
+
expect(result).toBe('https://presigned-url.example.com');
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('should create presigned URL for preview with custom expiration', async () => {
|
|
253
|
+
const s3 = new S3();
|
|
254
|
+
|
|
255
|
+
await s3.createPreSignedUrlForPreview('preview-file.jpg', 1800);
|
|
256
|
+
|
|
257
|
+
expect(mockGetSignedUrl).toHaveBeenCalledWith(expect.anything(), expect.anything(), {
|
|
258
|
+
expiresIn: 1800,
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
describe('uploadBuffer', () => {
|
|
264
|
+
it('should upload buffer with correct parameters', async () => {
|
|
265
|
+
const s3 = new S3();
|
|
266
|
+
mockS3ClientSend.mockResolvedValue({});
|
|
267
|
+
|
|
268
|
+
const buffer = Buffer.from('test data');
|
|
269
|
+
await s3.uploadBuffer('test-file.bin', buffer, 'application/octet-stream');
|
|
270
|
+
|
|
271
|
+
expect(PutObjectCommand).toHaveBeenCalledWith({
|
|
272
|
+
ACL: 'public-read',
|
|
273
|
+
Body: buffer,
|
|
274
|
+
Bucket: 'test-bucket',
|
|
275
|
+
ContentType: 'application/octet-stream',
|
|
276
|
+
Key: 'test-file.bin',
|
|
277
|
+
});
|
|
278
|
+
expect(mockS3ClientSend).toHaveBeenCalled();
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it('should upload buffer without content type', async () => {
|
|
282
|
+
const s3 = new S3();
|
|
283
|
+
mockS3ClientSend.mockResolvedValue({});
|
|
284
|
+
|
|
285
|
+
const buffer = Buffer.from('test data');
|
|
286
|
+
await s3.uploadBuffer('test-file.bin', buffer);
|
|
287
|
+
|
|
288
|
+
expect(PutObjectCommand).toHaveBeenCalledWith({
|
|
289
|
+
ACL: 'public-read',
|
|
290
|
+
Body: buffer,
|
|
291
|
+
Bucket: 'test-bucket',
|
|
292
|
+
ContentType: undefined,
|
|
293
|
+
Key: 'test-file.bin',
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
describe('uploadContent', () => {
|
|
299
|
+
it('should upload string content with correct parameters', async () => {
|
|
300
|
+
const s3 = new S3();
|
|
301
|
+
mockS3ClientSend.mockResolvedValue({});
|
|
302
|
+
|
|
303
|
+
const content = 'Hello, World!';
|
|
304
|
+
await s3.uploadContent('test-file.txt', content);
|
|
305
|
+
|
|
306
|
+
expect(PutObjectCommand).toHaveBeenCalledWith({
|
|
307
|
+
ACL: 'public-read',
|
|
308
|
+
Body: content,
|
|
309
|
+
Bucket: 'test-bucket',
|
|
310
|
+
Key: 'test-file.txt',
|
|
311
|
+
});
|
|
312
|
+
expect(mockS3ClientSend).toHaveBeenCalled();
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it('should handle empty content', async () => {
|
|
316
|
+
const s3 = new S3();
|
|
317
|
+
mockS3ClientSend.mockResolvedValue({});
|
|
318
|
+
|
|
319
|
+
await s3.uploadContent('empty.txt', '');
|
|
320
|
+
|
|
321
|
+
expect(PutObjectCommand).toHaveBeenCalledWith({
|
|
322
|
+
ACL: 'public-read',
|
|
323
|
+
Body: '',
|
|
324
|
+
Bucket: 'test-bucket',
|
|
325
|
+
Key: 'empty.txt',
|
|
326
|
+
});
|
|
327
|
+
});
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
describe('uploadMedia', () => {
|
|
331
|
+
it('should upload media with correct content type and cache control for JPEG', async () => {
|
|
332
|
+
const s3 = new S3();
|
|
333
|
+
mockS3ClientSend.mockResolvedValue({});
|
|
334
|
+
|
|
335
|
+
const buffer = Buffer.from('fake image data');
|
|
336
|
+
await s3.uploadMedia('image.jpg', buffer);
|
|
337
|
+
|
|
338
|
+
expect(PutObjectCommand).toHaveBeenCalledWith({
|
|
339
|
+
ACL: 'public-read',
|
|
340
|
+
Body: buffer,
|
|
341
|
+
Bucket: 'test-bucket',
|
|
342
|
+
CacheControl: expect.stringContaining('public, max-age='),
|
|
343
|
+
ContentType: 'image/jpeg',
|
|
344
|
+
Key: 'image.jpg',
|
|
345
|
+
});
|
|
346
|
+
expect(mockS3ClientSend).toHaveBeenCalled();
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it('should upload media with correct content type for PNG', async () => {
|
|
350
|
+
const s3 = new S3();
|
|
351
|
+
mockS3ClientSend.mockResolvedValue({});
|
|
352
|
+
|
|
353
|
+
const buffer = Buffer.from('fake image data');
|
|
354
|
+
await s3.uploadMedia('image.png', buffer);
|
|
355
|
+
|
|
356
|
+
expect(PutObjectCommand).toHaveBeenCalledWith(
|
|
357
|
+
expect.objectContaining({
|
|
358
|
+
ContentType: 'image/png',
|
|
359
|
+
Key: 'image.png',
|
|
360
|
+
}),
|
|
361
|
+
);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it('should upload media with correct content type for GIF', async () => {
|
|
365
|
+
const s3 = new S3();
|
|
366
|
+
mockS3ClientSend.mockResolvedValue({});
|
|
367
|
+
|
|
368
|
+
const buffer = Buffer.from('fake image data');
|
|
369
|
+
await s3.uploadMedia('animation.gif', buffer);
|
|
370
|
+
|
|
371
|
+
expect(PutObjectCommand).toHaveBeenCalledWith(
|
|
372
|
+
expect.objectContaining({
|
|
373
|
+
ContentType: 'image/gif',
|
|
374
|
+
Key: 'animation.gif',
|
|
375
|
+
}),
|
|
376
|
+
);
|
|
377
|
+
});
|
|
378
|
+
});
|
|
379
|
+
});
|