@xuanyue202/shared 2026.3.21
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/package.json +21 -0
- package/src/asr/README.md +102 -0
- package/src/asr/errors.ts +61 -0
- package/src/asr/index.ts +11 -0
- package/src/asr/tencent-flash.ts +165 -0
- package/src/cli/china-setup.test.ts +459 -0
- package/src/cli/china-setup.ts +1024 -0
- package/src/cli/index.ts +2 -0
- package/src/cli/install-hint.ts +97 -0
- package/src/cron/index.test.ts +57 -0
- package/src/cron/index.ts +130 -0
- package/src/file/file-utils.test.ts +141 -0
- package/src/file/file-utils.ts +284 -0
- package/src/file/index.ts +10 -0
- package/src/http/client.ts +141 -0
- package/src/http/index.ts +2 -0
- package/src/http/retry.ts +110 -0
- package/src/index.ts +12 -0
- package/src/logger/index.ts +1 -0
- package/src/logger/logger.ts +51 -0
- package/src/media/index.ts +65 -0
- package/src/media/media-io.ts +746 -0
- package/src/media/media-parser.ts +722 -0
- package/src/policy/dm-policy.ts +82 -0
- package/src/policy/group-policy.ts +93 -0
- package/src/policy/index.ts +2 -0
- package/src/types/common.ts +24 -0
- package/tsconfig.json +8 -0
- package/vitest.config.ts +8 -0
package/src/cli/index.ts
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { ChannelId } from "./china-setup.js";
|
|
2
|
+
|
|
3
|
+
type LoggerLike = {
|
|
4
|
+
info?: (message: string) => void;
|
|
5
|
+
warn?: (message: string) => void;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export type ChinaInstallHintApiLike = {
|
|
9
|
+
logger?: LoggerLike;
|
|
10
|
+
config?: unknown;
|
|
11
|
+
[key: string]: unknown;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const PROJECT_REPO = "https://github.com/BytePioneer-AI/openclaw-china";
|
|
15
|
+
const INSTALL_SETUP_COMMAND = "openclaw china setup";
|
|
16
|
+
const START_GATEWAY_COMMAND = "openclaw gateway --port 18789 --verbose";
|
|
17
|
+
const ANSI_RESET = "\u001b[0m";
|
|
18
|
+
const ANSI_BOLD = "\u001b[1m";
|
|
19
|
+
const ANSI_LINK = "\u001b[1;4;96m";
|
|
20
|
+
const ANSI_BORDER = "\u001b[92m";
|
|
21
|
+
const SUPPORTED_CHANNELS: readonly ChannelId[] = [
|
|
22
|
+
"dingtalk",
|
|
23
|
+
"feishu-china",
|
|
24
|
+
"wecom",
|
|
25
|
+
"wecom-app",
|
|
26
|
+
"wecom-kf",
|
|
27
|
+
"wechat-mp",
|
|
28
|
+
"qqbot",
|
|
29
|
+
];
|
|
30
|
+
const CHINA_INSTALL_HINT_SHOWN_KEY = Symbol.for("@xuanyue202/china-install-hint-shown");
|
|
31
|
+
|
|
32
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
33
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function hasAnyEnabledChinaChannel(config: unknown): boolean {
|
|
37
|
+
if (!isRecord(config)) {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const channels = config.channels;
|
|
42
|
+
if (!isRecord(channels)) {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return SUPPORTED_CHANNELS.some((channelId) => {
|
|
47
|
+
const channelConfig = channels[channelId];
|
|
48
|
+
return isRecord(channelConfig) && channelConfig.enabled === true;
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function hasShownInstallHint(): boolean {
|
|
53
|
+
const root = globalThis as Record<PropertyKey, unknown>;
|
|
54
|
+
return root[CHINA_INSTALL_HINT_SHOWN_KEY] === true;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function markInstallHintShown(): void {
|
|
58
|
+
const root = globalThis as Record<PropertyKey, unknown>;
|
|
59
|
+
root[CHINA_INSTALL_HINT_SHOWN_KEY] = true;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function showChinaInstallHint(api: ChinaInstallHintApiLike): void {
|
|
63
|
+
if (hasShownInstallHint() || hasAnyEnabledChinaChannel(api.config)) {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
markInstallHintShown();
|
|
67
|
+
|
|
68
|
+
const lines = [
|
|
69
|
+
`${ANSI_BORDER}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${ANSI_RESET}`,
|
|
70
|
+
" OpenClaw China Channels 已就绪!",
|
|
71
|
+
`${ANSI_BORDER}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${ANSI_RESET}`,
|
|
72
|
+
"",
|
|
73
|
+
"项目仓库:",
|
|
74
|
+
` ${ANSI_LINK}${PROJECT_REPO}${ANSI_RESET}`,
|
|
75
|
+
"",
|
|
76
|
+
"⭐ 如果这个项目对你有帮助,请给我们一个 Star!⭐",
|
|
77
|
+
"",
|
|
78
|
+
"下一步(配置引导):",
|
|
79
|
+
" 1. 运行交互式配置向导",
|
|
80
|
+
` ${ANSI_BOLD}${INSTALL_SETUP_COMMAND}${ANSI_RESET}`,
|
|
81
|
+
" 2. 按提示填写渠道凭据并保存配置",
|
|
82
|
+
" 3. 启动网关并观察日志",
|
|
83
|
+
` ${START_GATEWAY_COMMAND}`,
|
|
84
|
+
];
|
|
85
|
+
|
|
86
|
+
if (api.logger?.info) {
|
|
87
|
+
for (const line of lines) {
|
|
88
|
+
api.logger.info(line);
|
|
89
|
+
}
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
if (api.logger?.warn) {
|
|
93
|
+
for (const line of lines) {
|
|
94
|
+
api.logger.warn(line);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
appendCronHiddenPrompt,
|
|
4
|
+
applyCronHiddenPromptToContext,
|
|
5
|
+
shouldInjectCronHiddenPrompt,
|
|
6
|
+
splitCronHiddenPrompt,
|
|
7
|
+
} from "./index.js";
|
|
8
|
+
|
|
9
|
+
describe("cron hidden prompt", () => {
|
|
10
|
+
it("injects fixed delivery guidance for reminder-like messages", () => {
|
|
11
|
+
const text = "请帮我每小时提醒喝水";
|
|
12
|
+
const next = appendCronHiddenPrompt(text);
|
|
13
|
+
|
|
14
|
+
expect(shouldInjectCronHiddenPrompt(text)).toBe(true);
|
|
15
|
+
expect(next).not.toBe(text);
|
|
16
|
+
expect(next).toContain('sessionTarget="isolated"');
|
|
17
|
+
expect(next).toContain('payload.kind="agentTurn"');
|
|
18
|
+
expect(next).toContain("Use the built-in cron tool (action=add/update)");
|
|
19
|
+
expect(next).toContain("payload.message must be plain user-visible reminder text only");
|
|
20
|
+
expect(next).toContain('delivery.mode="announce"');
|
|
21
|
+
expect(next).toContain("delivery.channel=<OriginatingChannel>");
|
|
22
|
+
expect(next).toContain("delivery.to=<OriginatingTo>");
|
|
23
|
+
expect(next).toContain('Never set delivery.channel="last"');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("does not inject for documentation-style questions", () => {
|
|
27
|
+
const text = "cron 文档怎么用?";
|
|
28
|
+
const next = appendCronHiddenPrompt(text);
|
|
29
|
+
|
|
30
|
+
expect(shouldInjectCronHiddenPrompt(text)).toBe(false);
|
|
31
|
+
expect(next).toBe(text);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("is idempotent when called repeatedly", () => {
|
|
35
|
+
const text = "每分钟提醒我伸展一下";
|
|
36
|
+
const once = appendCronHiddenPrompt(text);
|
|
37
|
+
const twice = appendCronHiddenPrompt(once);
|
|
38
|
+
|
|
39
|
+
expect(twice).toBe(once);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("can split injected prompt from body", () => {
|
|
43
|
+
const injected = appendCronHiddenPrompt("set a reminder every day at 9am");
|
|
44
|
+
const result = splitCronHiddenPrompt(injected);
|
|
45
|
+
|
|
46
|
+
expect(result.base).toBe("set a reminder every day at 9am");
|
|
47
|
+
expect(result.prompt).toBeDefined();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("applies to context command body only when needed", () => {
|
|
51
|
+
const ctx: { Body?: string; CommandBody?: string } = { Body: "每天 18:00 提醒我下班喝水" };
|
|
52
|
+
const changed = applyCronHiddenPromptToContext(ctx);
|
|
53
|
+
|
|
54
|
+
expect(changed).toBe(true);
|
|
55
|
+
expect(ctx.CommandBody).toContain("delivery.mode=\"announce\"");
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
const CRON_HIDDEN_PROMPT = `
|
|
2
|
+
When creating or updating a cron task, always store a fixed delivery target in the job itself.
|
|
3
|
+
- Use the built-in cron tool (action=add/update). Do not run shell commands.
|
|
4
|
+
- Must use sessionTarget="isolated" for reminder jobs.
|
|
5
|
+
- payload.kind="agentTurn"
|
|
6
|
+
- payload.message must be plain user-visible reminder text only.
|
|
7
|
+
- You must encode runtime guardrails directly into payload.message so the cron run can follow them without extra context.
|
|
8
|
+
- Runtime guardrails to encode in payload.message:
|
|
9
|
+
- return plain text only
|
|
10
|
+
- never call any tool
|
|
11
|
+
- never call the message tool
|
|
12
|
+
- never send manually; delivery is handled by cron delivery settings
|
|
13
|
+
- Do not include tool directives, "NO_REPLY", or heartbeat markers in payload.message.
|
|
14
|
+
- Job name is never a message target.
|
|
15
|
+
- During cron run, must return plain text only and never call the message tool.
|
|
16
|
+
- Use top-level delivery with announce mode:
|
|
17
|
+
delivery.mode="announce"
|
|
18
|
+
delivery.channel=<OriginatingChannel> (example: "qqbot")
|
|
19
|
+
delivery.to=<OriginatingTo> (examples: "user:<openid>" / "group:<group_openid>")
|
|
20
|
+
delivery.accountId=<AccountId> when available
|
|
21
|
+
- Never set delivery.channel="last" for multi-channel environments.
|
|
22
|
+
- If OriginatingChannel/OriginatingTo are unavailable, ask a concise follow-up for channel and target.
|
|
23
|
+
- Do not call the message tool to send`;
|
|
24
|
+
|
|
25
|
+
const CRON_TRIGGER_KEYWORDS = [
|
|
26
|
+
"定时",
|
|
27
|
+
"提醒",
|
|
28
|
+
"每分钟",
|
|
29
|
+
"每小时",
|
|
30
|
+
"每天",
|
|
31
|
+
"每周",
|
|
32
|
+
"几点",
|
|
33
|
+
"早上",
|
|
34
|
+
"晚上",
|
|
35
|
+
"工作日",
|
|
36
|
+
"cron",
|
|
37
|
+
"remind",
|
|
38
|
+
"reminder",
|
|
39
|
+
"schedule",
|
|
40
|
+
"scheduled",
|
|
41
|
+
"every minute",
|
|
42
|
+
"every hour",
|
|
43
|
+
"every day",
|
|
44
|
+
"daily",
|
|
45
|
+
"every week",
|
|
46
|
+
"weekly",
|
|
47
|
+
"weekday",
|
|
48
|
+
"workday",
|
|
49
|
+
"morning",
|
|
50
|
+
"evening",
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
const CRON_TRIGGER_PATTERNS = [
|
|
54
|
+
/提醒我/u,
|
|
55
|
+
/帮我定时/u,
|
|
56
|
+
/每.+提醒/u,
|
|
57
|
+
/每天.+发/u,
|
|
58
|
+
/remind me/iu,
|
|
59
|
+
/set (a )?reminder/iu,
|
|
60
|
+
/every .+ remind/iu,
|
|
61
|
+
/every day .+ (send|post|notify)/iu,
|
|
62
|
+
/schedule .+ (reminder|message|notification)/iu,
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
const CRON_EXCLUDE_PATTERNS = [
|
|
66
|
+
/是什么意思/u,
|
|
67
|
+
/区别/u,
|
|
68
|
+
/为什么/u,
|
|
69
|
+
/\bhelp\b/iu,
|
|
70
|
+
/文档/u,
|
|
71
|
+
/怎么用/u,
|
|
72
|
+
/what does|what's|meaning of/iu,
|
|
73
|
+
/difference/iu,
|
|
74
|
+
/why/iu,
|
|
75
|
+
/\bdocs?\b/iu,
|
|
76
|
+
/documentation/iu,
|
|
77
|
+
/how to/iu,
|
|
78
|
+
/usage/iu,
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
export function shouldInjectCronHiddenPrompt(text: string): boolean {
|
|
82
|
+
const normalized = text.trim();
|
|
83
|
+
if (!normalized) return false;
|
|
84
|
+
const lowered = normalized.toLowerCase();
|
|
85
|
+
|
|
86
|
+
for (const pattern of CRON_EXCLUDE_PATTERNS) {
|
|
87
|
+
if (pattern.test(lowered)) return false;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
for (const keyword of CRON_TRIGGER_KEYWORDS) {
|
|
91
|
+
if (lowered.includes(keyword.toLowerCase())) return true;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return CRON_TRIGGER_PATTERNS.some((pattern) => pattern.test(normalized));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function splitCronHiddenPrompt(text: string): { base: string; prompt?: string } {
|
|
98
|
+
const idx = text.indexOf(CRON_HIDDEN_PROMPT);
|
|
99
|
+
if (idx === -1) {
|
|
100
|
+
return { base: text };
|
|
101
|
+
}
|
|
102
|
+
const base = text.slice(0, idx).trimEnd();
|
|
103
|
+
return { base, prompt: CRON_HIDDEN_PROMPT };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function appendCronHiddenPrompt(text: string): string {
|
|
107
|
+
if (!shouldInjectCronHiddenPrompt(text)) return text;
|
|
108
|
+
if (text.includes(CRON_HIDDEN_PROMPT)) return text;
|
|
109
|
+
return `${text}\n\n${CRON_HIDDEN_PROMPT}`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function applyCronHiddenPromptToContext<
|
|
113
|
+
T extends { Body?: string; RawBody?: string; CommandBody?: string }
|
|
114
|
+
>(ctx: T): boolean {
|
|
115
|
+
const base =
|
|
116
|
+
(typeof ctx.RawBody === "string" && ctx.RawBody) ||
|
|
117
|
+
(typeof ctx.Body === "string" && ctx.Body) ||
|
|
118
|
+
(typeof ctx.CommandBody === "string" && ctx.CommandBody) ||
|
|
119
|
+
"";
|
|
120
|
+
|
|
121
|
+
if (!base) return false;
|
|
122
|
+
|
|
123
|
+
const next = appendCronHiddenPrompt(base);
|
|
124
|
+
if (next === base) return false;
|
|
125
|
+
|
|
126
|
+
ctx.CommandBody = next;
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export { CRON_HIDDEN_PROMPT };
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit Tests for File Utilities
|
|
3
|
+
*
|
|
4
|
+
* Feature: dingtalk-media-receive
|
|
5
|
+
* Validates: Requirements 5.1-5.8, 6.1-6.6
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, it, expect } from "vitest";
|
|
9
|
+
import { resolveFileCategory, resolveExtension } from "./file-utils.js";
|
|
10
|
+
|
|
11
|
+
describe("resolveFileCategory", () => {
|
|
12
|
+
// Image categorization (Requirement 5.1)
|
|
13
|
+
it("should categorize image MIME types", () => {
|
|
14
|
+
expect(resolveFileCategory("image/jpeg")).toBe("image");
|
|
15
|
+
expect(resolveFileCategory("image/png")).toBe("image");
|
|
16
|
+
expect(resolveFileCategory("image/gif")).toBe("image");
|
|
17
|
+
expect(resolveFileCategory("image/webp")).toBe("image");
|
|
18
|
+
expect(resolveFileCategory("image/bmp")).toBe("image");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
// Audio categorization (Requirement 5.2)
|
|
22
|
+
it("should categorize audio MIME types", () => {
|
|
23
|
+
expect(resolveFileCategory("audio/mpeg")).toBe("audio");
|
|
24
|
+
expect(resolveFileCategory("audio/wav")).toBe("audio");
|
|
25
|
+
expect(resolveFileCategory("audio/ogg")).toBe("audio");
|
|
26
|
+
expect(resolveFileCategory("audio/amr")).toBe("audio");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// Video categorization (Requirement 5.3)
|
|
30
|
+
it("should categorize video MIME types", () => {
|
|
31
|
+
expect(resolveFileCategory("video/mp4")).toBe("video");
|
|
32
|
+
expect(resolveFileCategory("video/quicktime")).toBe("video");
|
|
33
|
+
expect(resolveFileCategory("video/webm")).toBe("video");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// Document categorization (Requirement 5.4)
|
|
37
|
+
it("should categorize document MIME types", () => {
|
|
38
|
+
expect(resolveFileCategory("application/pdf")).toBe("document");
|
|
39
|
+
expect(resolveFileCategory("application/msword")).toBe("document");
|
|
40
|
+
expect(resolveFileCategory("text/plain")).toBe("document");
|
|
41
|
+
expect(resolveFileCategory("text/markdown")).toBe("document");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// Archive categorization (Requirement 5.5)
|
|
45
|
+
it("should categorize archive MIME types", () => {
|
|
46
|
+
expect(resolveFileCategory("application/zip")).toBe("archive");
|
|
47
|
+
expect(resolveFileCategory("application/x-rar-compressed")).toBe("archive");
|
|
48
|
+
expect(resolveFileCategory("application/x-7z-compressed")).toBe("archive");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// Code categorization (Requirement 5.6)
|
|
52
|
+
it("should categorize code MIME types", () => {
|
|
53
|
+
expect(resolveFileCategory("application/json")).toBe("code");
|
|
54
|
+
expect(resolveFileCategory("text/html")).toBe("code");
|
|
55
|
+
expect(resolveFileCategory("text/css")).toBe("code");
|
|
56
|
+
expect(resolveFileCategory("text/javascript")).toBe("code");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// Extension fallback (Requirement 5.8)
|
|
60
|
+
it("should use extension fallback when MIME type is unknown", () => {
|
|
61
|
+
expect(resolveFileCategory("application/octet-stream", "photo.jpg")).toBe("image");
|
|
62
|
+
expect(resolveFileCategory("application/octet-stream", "song.mp3")).toBe("audio");
|
|
63
|
+
expect(resolveFileCategory("application/octet-stream", "movie.mp4")).toBe("video");
|
|
64
|
+
expect(resolveFileCategory("application/octet-stream", "doc.pdf")).toBe("document");
|
|
65
|
+
expect(resolveFileCategory("application/octet-stream", "archive.zip")).toBe("archive");
|
|
66
|
+
expect(resolveFileCategory("application/octet-stream", "script.py")).toBe("code");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// Other category (Requirement 5.7)
|
|
70
|
+
it("should return 'other' for unknown types", () => {
|
|
71
|
+
expect(resolveFileCategory("application/octet-stream")).toBe("other");
|
|
72
|
+
expect(resolveFileCategory("application/unknown")).toBe("other");
|
|
73
|
+
expect(resolveFileCategory("application/octet-stream", "file.xyz")).toBe("other");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// MIME type normalization
|
|
77
|
+
it("should handle MIME types with parameters", () => {
|
|
78
|
+
expect(resolveFileCategory("image/jpeg; charset=utf-8")).toBe("image");
|
|
79
|
+
expect(resolveFileCategory("text/plain; charset=utf-8")).toBe("document");
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe("resolveExtension", () => {
|
|
84
|
+
// Image extensions (Requirement 6.1)
|
|
85
|
+
it("should resolve image MIME types to extensions", () => {
|
|
86
|
+
expect(resolveExtension("image/jpeg")).toBe(".jpg");
|
|
87
|
+
expect(resolveExtension("image/png")).toBe(".png");
|
|
88
|
+
expect(resolveExtension("image/gif")).toBe(".gif");
|
|
89
|
+
expect(resolveExtension("image/webp")).toBe(".webp");
|
|
90
|
+
expect(resolveExtension("image/bmp")).toBe(".bmp");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// Audio extensions (Requirement 6.2)
|
|
94
|
+
it("should resolve audio MIME types to extensions", () => {
|
|
95
|
+
expect(resolveExtension("audio/mpeg")).toBe(".mp3");
|
|
96
|
+
expect(resolveExtension("audio/wav")).toBe(".wav");
|
|
97
|
+
expect(resolveExtension("audio/ogg")).toBe(".ogg");
|
|
98
|
+
expect(resolveExtension("audio/amr")).toBe(".amr");
|
|
99
|
+
expect(resolveExtension("audio/x-m4a")).toBe(".m4a");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// Video extensions (Requirement 6.3)
|
|
103
|
+
it("should resolve video MIME types to extensions", () => {
|
|
104
|
+
expect(resolveExtension("video/mp4")).toBe(".mp4");
|
|
105
|
+
expect(resolveExtension("video/quicktime")).toBe(".mov");
|
|
106
|
+
expect(resolveExtension("video/x-msvideo")).toBe(".avi");
|
|
107
|
+
expect(resolveExtension("video/webm")).toBe(".webm");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// Document extensions (Requirement 6.4)
|
|
111
|
+
it("should resolve document MIME types to extensions", () => {
|
|
112
|
+
expect(resolveExtension("application/pdf")).toBe(".pdf");
|
|
113
|
+
expect(resolveExtension("application/msword")).toBe(".doc");
|
|
114
|
+
expect(resolveExtension("application/vnd.openxmlformats-officedocument.wordprocessingml.document")).toBe(".docx");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// Default extension (Requirement 6.5)
|
|
118
|
+
it("should return .bin for unknown MIME types", () => {
|
|
119
|
+
expect(resolveExtension("application/unknown")).toBe(".bin");
|
|
120
|
+
expect(resolveExtension("application/octet-stream")).toBe(".bin");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// fileName precedence (Requirement 6.6)
|
|
124
|
+
it("should use fileName extension when provided", () => {
|
|
125
|
+
expect(resolveExtension("application/octet-stream", "photo.jpg")).toBe(".jpg");
|
|
126
|
+
expect(resolveExtension("image/png", "custom.jpeg")).toBe(".jpeg");
|
|
127
|
+
expect(resolveExtension("application/unknown", "document.pdf")).toBe(".pdf");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// MIME type normalization
|
|
131
|
+
it("should handle MIME types with parameters", () => {
|
|
132
|
+
expect(resolveExtension("image/jpeg; charset=utf-8")).toBe(".jpg");
|
|
133
|
+
expect(resolveExtension("audio/mpeg; bitrate=320")).toBe(".mp3");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// Edge cases
|
|
137
|
+
it("should handle fileName without extension", () => {
|
|
138
|
+
expect(resolveExtension("image/jpeg", "photo")).toBe(".jpg");
|
|
139
|
+
expect(resolveExtension("application/unknown", "noext")).toBe(".bin");
|
|
140
|
+
});
|
|
141
|
+
});
|